9 Commits

Author SHA1 Message Date
LD50
6d39540e8d Merge pull request #107 from LD-Reborn/86-add-embeddingsearch-logo-and-improve-navbar
Added logo to navbar, reworked navbar layout
2026-01-22 19:43:49 +01:00
328615be97 Added logo to navbar, reworked navbar layout 2026-01-22 19:43:28 +01:00
LD50
20cbbfd06c Merge pull request #106 from LD-Reborn/18-update-to-dotnet-10
18 update to dotnet 10
2026-01-22 16:48:22 +01:00
cfeefa385a Removed magic number from SearchdomainHelper float-bytes converter methods 2026-01-22 16:47:48 +01:00
49ecb06fb0 Updated packages 2026-01-22 16:47:11 +01:00
LD50
a15548ea77 Update issue templates 2026-01-22 14:35:52 +01:00
e2cfe56b49 Updated projects to dotnet 10 2026-01-22 00:46:56 +01:00
LD50
9c306a0917 Merge pull request #102 from LD-Reborn/91-add-persistent-embedding-cache
Added persistent embedding cache
2026-01-21 23:58:29 +01:00
5f05aac909 Added persistent embedding cache 2026-01-21 23:54:08 +01:00
23 changed files with 710 additions and 82 deletions

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version / Commit ID [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

1
.gitignore vendored
View File

@@ -20,3 +20,4 @@ src/Shared/obj
src/Server/wwwroot/logs/* src/Server/wwwroot/logs/*
src/Server/Tools/CriticalCSS/node_modules src/Server/Tools/CriticalCSS/node_modules
src/Server/Tools/CriticalCSS/package*.json src/Server/Tools/CriticalCSS/package*.json
*.db*

View File

@@ -5,7 +5,7 @@
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>

View File

@@ -1,22 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ElmahCore" Version="2.1.2" /> <PackageReference Include="ElmahCore" Version="2.1.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.14" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.14.0" /> <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.8" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.2" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.0" /> <PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.0" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="9.0.3" /> <PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.2" />
<PackageReference Include="Python" Version="3.13.3" /> <PackageReference Include="Python" Version="3.14.2" />
<PackageReference Include="Pythonnet" Version="3.0.5" /> <PackageReference Include="Pythonnet" Version="3.0.5" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,242 @@
using System.Configuration;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Options;
using OllamaSharp.Models;
using Server.Models;
using Shared;
namespace Server.Helper;
public static class CacheHelper
{
public static EnumerableLruCache<string, Dictionary<string, float[]>> GetEmbeddingStore(EmbeddingSearchOptions options)
{
SQLiteHelper helper = new(options);
EnumerableLruCache<string, Dictionary<string, float[]>> embeddingCache = new((int)(options.Cache.StoreTopN ?? options.Cache.CacheTopN));
helper.ExecuteQuery(
"SELECT cache_key, model_key, embedding, idx FROM embedding_cache ORDER BY idx ASC", [], r =>
{
int embeddingOrdinal = r.GetOrdinal("embedding");
int length = (int)r.GetBytes(embeddingOrdinal, 0, null, 0, 0);
byte[] buffer = new byte[length];
r.GetBytes(embeddingOrdinal, 0, buffer, 0, length);
var cache_key = r.GetString(r.GetOrdinal("cache_key"));
var model_key = r.GetString(r.GetOrdinal("model_key"));
var embedding = SearchdomainHelper.FloatArrayFromBytes(buffer);
var index = r.GetInt32(r.GetOrdinal("idx"));
if (cache_key is null || model_key is null || embedding is null)
{
throw new Exception("Unable to get the embedding store due to a returned element being null");
}
if (!embeddingCache.TryGetValue(cache_key, out Dictionary<string, float[]>? keyElement) || keyElement is null)
{
keyElement = [];
embeddingCache[cache_key] = keyElement;
}
keyElement[model_key] = embedding;
return 0;
}
);
embeddingCache.Capacity = (int)options.Cache.CacheTopN;
return embeddingCache;
}
public static async Task UpdateEmbeddingStore(EnumerableLruCache<string, Dictionary<string, float[]>> embeddingCache, EmbeddingSearchOptions options)
{
if (options.Cache.StoreTopN is not null)
{
embeddingCache.Capacity = (int)options.Cache.StoreTopN;
}
SQLiteHelper helper = new(options);
EnumerableLruCache<string, Dictionary<string, float[]>> embeddingStore = GetEmbeddingStore(options);
var embeddingCacheMappings = GetCacheMappings(embeddingCache);
var embeddingCacheIndexMap = embeddingCacheMappings.positionToEntry;
var embeddingCacheObjectMap = embeddingCacheMappings.entryToPosition;
var embeddingStoreMappings = GetCacheMappings(embeddingStore);
var embeddingStoreIndexMap = embeddingStoreMappings.positionToEntry;
var embeddingStoreObjectMap = embeddingStoreMappings.entryToPosition;
List<int> deletedEntries = [];
foreach (KeyValuePair<int, KeyValuePair<string, Dictionary<string, float[]>>> kv in embeddingStoreIndexMap)
{
int storeEntryIndex = kv.Key;
string storeEntryString = kv.Value.Key;
bool cacheEntryExists = embeddingCacheObjectMap.TryGetValue(storeEntryString, out int cacheEntryIndex);
if (!cacheEntryExists) // Deleted
{
deletedEntries.Add(storeEntryIndex);
}
}
Task removeEntriesFromStoreTask = RemoveEntriesFromStore(helper, deletedEntries);
List<(int Index, KeyValuePair<string, Dictionary<string, float[]>> Entry)> createdEntries = [];
List<(int Index, int NewIndex)> changedEntries = [];
List<(int Index, string Model, string Key, float[] Embedding)> AddedModels = [];
List<(int Index, string Model)> RemovedModels = [];
foreach (KeyValuePair<int, KeyValuePair<string, Dictionary<string, float[]>>> kv in embeddingCacheIndexMap)
{
int cacheEntryIndex = kv.Key;
string cacheEntryString = kv.Value.Key;
bool storeEntryExists = embeddingStoreObjectMap.TryGetValue(cacheEntryString, out int storeEntryIndex);
if (!storeEntryExists) // Created
{
createdEntries.Add((
Index: cacheEntryIndex,
Entry: kv.Value
));
continue;
}
if (cacheEntryIndex != storeEntryIndex) // Changed
{
changedEntries.Add((
Index: cacheEntryIndex,
NewIndex: storeEntryIndex
));
}
// Check for new/removed models
var storeModels = embeddingStoreIndexMap[storeEntryIndex].Value;
var cacheModels = kv.Value.Value;
// New models
foreach (var model in storeModels.Keys.Except(cacheModels.Keys))
{
RemovedModels.Add((
Index: cacheEntryIndex,
Model: model
));
}
// Removed models
foreach (var model in cacheModels.Keys.Except(storeModels.Keys))
{
AddedModels.Add((
Index: cacheEntryIndex,
Model: model,
Key: cacheEntryString,
Embedding: cacheModels[model]
));
}
}
var taskSet = new List<Task>
{
removeEntriesFromStoreTask,
CreateEntriesInStore(helper, createdEntries),
UpdateEntryIndicesInStore(helper, changedEntries),
AddModelsToIndices(helper, AddedModels),
RemoveModelsFromIndices(helper, RemovedModels)
};
await Task.WhenAll(taskSet);
}
private static async Task CreateEntriesInStore(
SQLiteHelper helper,
List<(int Index, KeyValuePair<string, Dictionary<string, float[]>> Entry)> createdEntries)
{
helper.BulkExecuteNonQuery(
"INSERT INTO embedding_cache (cache_key, model_key, embedding, idx) VALUES (@cache_key, @model_key, @embedding, @index)",
createdEntries.SelectMany(element => {
return element.Entry.Value.Select(model => new object[]
{
new SqliteParameter("@cache_key", element.Entry.Key),
new SqliteParameter("@model_key", model.Key),
new SqliteParameter("@embedding", SearchdomainHelper.BytesFromFloatArray(model.Value)),
new SqliteParameter("@index", element.Index)
});
})
);
}
private static async Task UpdateEntryIndicesInStore(
SQLiteHelper helper,
List<(int Index, int NewIndex)> changedEntries)
{
helper.BulkExecuteNonQuery(
"UPDATE embedding_cache SET idx = @newIndex WHERE idx = @index",
changedEntries.Select(element => new object[]
{
new SqliteParameter("@index", element.Index),
new SqliteParameter("@newIndex", -element.NewIndex) // The "-" prevents in-place update collisions
})
);
helper.BulkExecuteNonQuery(
"UPDATE embedding_cache SET idx = @newIndex WHERE idx = @index",
changedEntries.Select(element => new object[]
{
new SqliteParameter("@index", -element.NewIndex),
new SqliteParameter("@newIndex", element.NewIndex) // Flip the negative prefix
})
);
}
private static async Task RemoveEntriesFromStore(
SQLiteHelper helper,
List<int> deletedEntries)
{
helper.BulkExecuteNonQuery(
"DELETE FROM embedding_cache WHERE idx = @index",
deletedEntries.Select(index => new object[]
{
new SqliteParameter("@index", index)
})
);
}
private static async Task AddModelsToIndices(
SQLiteHelper helper,
List<(int Index, string Model, string Key, float[] Embedding)> addedModels)
{
helper.BulkExecuteNonQuery(
"INSERT INTO embedding_cache (cache_key, model_key, embedding, idx) VALUES (@cache_key, @model_key, @embedding, @index)",
addedModels.Select(element => new object[]
{
new SqliteParameter("@cache_key", element.Key),
new SqliteParameter("@model_key", element.Model),
new SqliteParameter("@embedding", SearchdomainHelper.BytesFromFloatArray(element.Embedding)),
new SqliteParameter("@index", element.Index)
})
);
}
private static async Task RemoveModelsFromIndices(
SQLiteHelper helper,
List<(int Index, string Model)> removedModels)
{
helper.BulkExecuteNonQuery(
"DELETE FROM embedding_cache WHERE idx = @index AND model_key = @model",
removedModels.Select(element => new object[]
{
new SqliteParameter("@index", element.Index),
new SqliteParameter("@model", element.Model)
})
);
}
private static (Dictionary<int, KeyValuePair<string, Dictionary<string, float[]>>> positionToEntry,
Dictionary<string, int> entryToPosition)
GetCacheMappings(EnumerableLruCache<string, Dictionary<string, float[]>> embeddingCache)
{
var positionToEntry = new Dictionary<int, KeyValuePair<string, Dictionary<string, float[]>>>();
var entryToPosition = new Dictionary<string, int>();
int position = 0;
foreach (var entry in embeddingCache)
{
positionToEntry[position] = entry;
entryToPosition[entry.Key] = position;
position++;
}
return (positionToEntry, entryToPosition);
}
}

View File

@@ -0,0 +1,76 @@
using System.Data;
using System.Data.Common;
using Microsoft.Data.Sqlite;
using Server.Models;
using MySql.Data.MySqlClient;
using System.Configuration;
namespace Server.Helper;
public class SQLiteHelper : SqlHelper, IDisposable
{
public SQLiteHelper(DbConnection connection, string connectionString) : base(connection, connectionString)
{
Connection = connection;
ConnectionString = connectionString;
}
public SQLiteHelper(EmbeddingSearchOptions options) : base(new SqliteConnection(options.ConnectionStrings.Cache), options.ConnectionStrings.Cache ?? "")
{
if (options.ConnectionStrings.Cache is null)
{
throw new ConfigurationErrorsException("Cache options must not be null when instantiating SQLiteHelper");
}
ConnectionString = options.ConnectionStrings.Cache;
Connection = new SqliteConnection(ConnectionString);
}
public override SQLiteHelper DuplicateConnection()
{
SqliteConnection newConnection = new(ConnectionString);
return new SQLiteHelper(newConnection, ConnectionString);
}
public override int ExecuteSQLCommandGetInsertedID(string query, object[] parameters)
{
lock (Connection)
{
EnsureConnected();
EnsureDbReaderIsClosed();
using DbCommand command = Connection.CreateCommand();
command.CommandText = query;
command.Parameters.AddRange(parameters);
command.ExecuteNonQuery();
command.CommandText = "SELECT last_insert_rowid();";
return Convert.ToInt32(command.ExecuteScalar());
}
}
public int BulkExecuteNonQuery(string sql, IEnumerable<object[]> parameterSets)
{
lock (Connection)
{
EnsureConnected();
EnsureDbReaderIsClosed();
using var transaction = Connection.BeginTransaction();
using var command = Connection.CreateCommand();
command.CommandText = sql;
command.Transaction = transaction;
int affectedRows = 0;
foreach (var parameters in parameterSets)
{
command.Parameters.Clear();
command.Parameters.AddRange(parameters);
affectedRows += command.ExecuteNonQuery();
}
transaction.Commit();
return affectedRows;
}
}
}

View File

@@ -16,7 +16,7 @@ public class SearchdomainHelper(ILogger<SearchdomainHelper> logger, DatabaseHelp
public static byte[] BytesFromFloatArray(float[] floats) public static byte[] BytesFromFloatArray(float[] floats)
{ {
var byteArray = new byte[floats.Length * 4]; var byteArray = new byte[floats.Length * sizeof(float)];
var floatArray = floats.ToArray(); var floatArray = floats.ToArray();
Buffer.BlockCopy(floatArray, 0, byteArray, 0, byteArray.Length); Buffer.BlockCopy(floatArray, 0, byteArray, 0, byteArray.Length);
return byteArray; return byteArray;
@@ -24,7 +24,7 @@ public class SearchdomainHelper(ILogger<SearchdomainHelper> logger, DatabaseHelp
public static float[] FloatArrayFromBytes(byte[] bytes) public static float[] FloatArrayFromBytes(byte[] bytes)
{ {
var floatArray = new float[bytes.Length / 4]; var floatArray = new float[bytes.Length / sizeof(float)];
Buffer.BlockCopy(bytes, 0, floatArray, 0, bytes.Length); Buffer.BlockCopy(bytes, 0, floatArray, 0, bytes.Length);
return floatArray; return floatArray;
} }

View File

@@ -0,0 +1,65 @@
using System.Data.Common;
public static class SQLiteMigrations
{
public static void Migrate(DbConnection conn)
{
EnableWal(conn);
using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA user_version;";
var version = Convert.ToInt32(cmd.ExecuteScalar());
if (version == 0)
{
CreateV1(conn);
SetVersion(conn, 1);
version = 1;
}
if (version == 1)
{
// future migration
// UpdateFrom1To2(conn);
// SetVersion(conn, 2);
}
}
private static void EnableWal(DbConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA journal_mode = WAL;";
cmd.ExecuteNonQuery();
}
private static void CreateV1(DbConnection conn)
{
using var tx = conn.BeginTransaction();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
CREATE TABLE embedding_cache (
cache_key TEXT NOT NULL,
model_key TEXT NOT NULL,
embedding BLOB NOT NULL,
idx INTEGER NOT NULL,
PRIMARY KEY (cache_key, model_key)
);
CREATE INDEX idx_index
ON embedding_cache(idx);
""";
cmd.ExecuteNonQuery();
tx.Commit();
}
private static void SetVersion(DbConnection conn, int version)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = $"PRAGMA user_version = {version};";
cmd.ExecuteNonQuery();
}
}

View File

@@ -8,9 +8,9 @@ public class EmbeddingSearchOptions : ApiKeyOptions
{ {
public required ConnectionStringsOptions ConnectionStrings { get; set; } public required ConnectionStringsOptions ConnectionStrings { get; set; }
public ElmahOptions? Elmah { get; set; } public ElmahOptions? Elmah { get; set; }
public required long EmbeddingCacheMaxCount { get; set; }
public required Dictionary<string, AiProvider> AiProviders { get; set; } public required Dictionary<string, AiProvider> AiProviders { get; set; }
public required SimpleAuthOptions SimpleAuth { get; set; } public required SimpleAuthOptions SimpleAuth { get; set; }
public required CacheOptions Cache { get; set; }
public required bool UseHttpsRedirection { get; set; } public required bool UseHttpsRedirection { get; set; }
} }
@@ -38,4 +38,12 @@ public class SimpleUser
public class ConnectionStringsOptions public class ConnectionStringsOptions
{ {
public required string SQL { get; set; } public required string SQL { get; set; }
public string? Cache { get; set; }
}
public class CacheOptions
{
public required long CacheTopN { get; set; }
public bool StoreEmbeddingCache { get; set; } = false;
public int? StoreTopN { get; set; }
} }

View File

@@ -0,0 +1,109 @@
namespace Server.Models;
using System.Data.Common;
public abstract partial class SqlHelper : IDisposable
{
public DbConnection Connection { get; set; }
public DbDataReader? DbDataReader { get; set; }
public string ConnectionString { get; set; }
public SqlHelper(DbConnection connection, string connectionString)
{
Connection = connection;
ConnectionString = connectionString;
}
public abstract SqlHelper DuplicateConnection();
public void Dispose()
{
Connection.Close();
GC.SuppressFinalize(this);
}
public DbDataReader ExecuteSQLCommand(string query, object[] parameters)
{
lock (Connection)
{
EnsureConnected();
EnsureDbReaderIsClosed();
using DbCommand command = Connection.CreateCommand();
command.CommandText = query;
command.Parameters.AddRange(parameters);
DbDataReader = command.ExecuteReader();
return DbDataReader;
}
}
public void ExecuteQuery<T>(string query, object[] parameters, Func<DbDataReader, T> map)
{
lock (Connection)
{
EnsureConnected();
EnsureDbReaderIsClosed();
using var command = Connection.CreateCommand();
command.CommandText = query;
command.Parameters.AddRange(parameters);
using var reader = command.ExecuteReader();
while (reader.Read())
{
map(reader);
}
return;
}
}
public int ExecuteSQLNonQuery(string query, object[] parameters)
{
lock (Connection)
{
EnsureConnected();
EnsureDbReaderIsClosed();
using DbCommand command = Connection.CreateCommand();
command.CommandText = query;
command.Parameters.AddRange(parameters);
return command.ExecuteNonQuery();
}
}
public abstract int ExecuteSQLCommandGetInsertedID(string query, object[] parameters);
public bool EnsureConnected()
{
if (Connection.State != System.Data.ConnectionState.Open)
{
try
{
Connection.Close();
Connection.Open();
}
catch (Exception ex)
{
ElmahCore.ElmahExtensions.RaiseError(ex);
throw;
}
}
return true;
}
public void EnsureDbReaderIsClosed()
{
int counter = 0;
int sleepTime = 10;
int timeout = 5000;
while (!(DbDataReader?.IsClosed ?? true))
{
if (counter > timeout / sleepTime)
{
TimeoutException ex = new("Unable to ensure dbDataReader is closed");
ElmahCore.ElmahExtensions.RaiseError(ex);
throw ex;
}
Thread.Sleep(sleepTime);
}
}
}

View File

@@ -9,14 +9,14 @@ using Server.Helper;
using Server.Models; using Server.Models;
using Server.Services; using Server.Services;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Reflection;
using System.Configuration; using System.Configuration;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi;
using Shared.Models; using Shared.Models;
using Microsoft.AspNetCore.ResponseCompression; using Microsoft.AspNetCore.ResponseCompression;
using System.Net; using System.Net;
using System.Text; using System.Text;
using Server.Migrations; using Server.Migrations;
using Microsoft.Data.Sqlite;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -39,6 +39,15 @@ builder.Services.Configure<ApiKeyOptions>(configurationSection);
var helper = new SQLHelper(new MySql.Data.MySqlClient.MySqlConnection(configuration.ConnectionStrings.SQL), configuration.ConnectionStrings.SQL); var helper = new SQLHelper(new MySql.Data.MySqlClient.MySqlConnection(configuration.ConnectionStrings.SQL), configuration.ConnectionStrings.SQL);
DatabaseMigrations.Migrate(helper); DatabaseMigrations.Migrate(helper);
// Migrate SQLite cache
if (configuration.ConnectionStrings.Cache is not null)
{
var SqliteConnection = new SqliteConnection(configuration.ConnectionStrings.Cache);
SqliteConnection.Open();
SQLiteMigrations.Migrate(SqliteConnection);
}
// Add Localization // Add Localization
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources"); builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
builder.Services.Configure<RequestLocalizationOptions>(options => builder.Services.Configure<RequestLocalizationOptions>(options =>
@@ -54,36 +63,37 @@ builder.Services.AddScoped<LocalizationService>();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c => builder.Services.AddOpenApi(options =>
{ {
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; options.AddDocumentTransformer((document, context, _) =>
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
c.IncludeXmlComments(xmlPath);
if (configuration.ApiKeys is not null)
{ {
c.AddSecurityDefinition("ApiKey", new OpenApiSecurityScheme if (configuration.ApiKeys is null)
return Task.CompletedTask;
document.Components ??= new();
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
document.Components.SecuritySchemes["ApiKey"] =
new OpenApiSecurityScheme
{ {
Description = "ApiKey must appear in header",
Type = SecuritySchemeType.ApiKey, Type = SecuritySchemeType.ApiKey,
Name = "X-API-KEY", Name = "X-API-KEY",
In = ParameterLocation.Header, In = ParameterLocation.Header,
Scheme = "ApiKeyScheme" Description = "ApiKey must appear in header"
});
var key = new OpenApiSecurityScheme()
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "ApiKey"
},
In = ParameterLocation.Header
}; };
var requirement = new OpenApiSecurityRequirement
document.Security ??= [];
// Apply globally
document.Security?.Add(
new OpenApiSecurityRequirement
{ {
{ key, []} [new OpenApiSecuritySchemeReference("ApiKey", document)] = []
};
c.AddSecurityRequirement(requirement);
} }
);
return Task.CompletedTask;
});
}); });
Log.Logger = new LoggerConfiguration() Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration) .ReadFrom.Configuration(builder.Configuration)
@@ -232,13 +242,16 @@ app.Use(async (context, next) =>
await next(); await next();
}); });
app.UseSwagger();
app.UseSwaggerUI(options => app.UseSwaggerUI(options =>
{ {
options.SwaggerEndpoint("/openapi/v1.json", "API v1");
options.RoutePrefix = "swagger";
options.EnablePersistAuthorization(); options.EnablePersistAuthorization();
options.InjectStylesheet("/swagger-ui/custom.css"); options.InjectStylesheet("/swagger-ui/custom.css");
options.InjectJavascript("/swagger-ui/custom.js"); options.InjectJavascript("/swagger-ui/custom.js");
}); });
app.MapOpenApi("/openapi/v1.json");
//app.UseElmahExceptionPage(); // Messes with JSON response for API calls. Leaving this here so I don't accidentally put this in again later on. //app.UseElmahExceptionPage(); // Messes with JSON response for API calls. Leaving this here so I don't accidentally put this in again later on.
if (configuration.ApiKeys is not null) if (configuration.ApiKeys is not null)

View File

@@ -9,10 +9,11 @@ using System.Text.Json;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Server.Models; using Server.Models;
using Shared; using Shared;
using System.Diagnostics;
namespace Server; namespace Server;
public class SearchdomainManager public class SearchdomainManager : IDisposable
{ {
private Dictionary<string, Searchdomain> searchdomains = []; private Dictionary<string, Searchdomain> searchdomains = [];
private readonly ILogger<SearchdomainManager> _logger; private readonly ILogger<SearchdomainManager> _logger;
@@ -24,6 +25,7 @@ public class SearchdomainManager
public SQLHelper helper; public SQLHelper helper;
public EnumerableLruCache<string, Dictionary<string, float[]>> embeddingCache; public EnumerableLruCache<string, Dictionary<string, float[]>> embeddingCache;
public long EmbeddingCacheMaxCount; public long EmbeddingCacheMaxCount;
private bool disposed = false;
public SearchdomainManager(ILogger<SearchdomainManager> logger, IOptions<EmbeddingSearchOptions> options, AIProvider aIProvider, DatabaseHelper databaseHelper) public SearchdomainManager(ILogger<SearchdomainManager> logger, IOptions<EmbeddingSearchOptions> options, AIProvider aIProvider, DatabaseHelper databaseHelper)
{ {
@@ -31,8 +33,17 @@ public class SearchdomainManager
_options = options.Value; _options = options.Value;
this.aIProvider = aIProvider; this.aIProvider = aIProvider;
_databaseHelper = databaseHelper; _databaseHelper = databaseHelper;
EmbeddingCacheMaxCount = _options.EmbeddingCacheMaxCount; EmbeddingCacheMaxCount = _options.Cache.CacheTopN;
if (options.Value.Cache.StoreEmbeddingCache)
{
var stopwatch = Stopwatch.StartNew();
embeddingCache = CacheHelper.GetEmbeddingStore(options.Value);
stopwatch.Stop();
_logger.LogInformation("GetEmbeddingStore completed in {ElapsedMilliseconds} ms", stopwatch.ElapsedMilliseconds);
} else
{
embeddingCache = new((int)EmbeddingCacheMaxCount); embeddingCache = new((int)EmbeddingCacheMaxCount);
}
connectionString = _options.ConnectionStrings.SQL; connectionString = _options.ConnectionStrings.SQL;
connection = new MySqlConnection(connectionString); connection = new MySqlConnection(connectionString);
connection.Open(); connection.Open();
@@ -127,4 +138,39 @@ public class SearchdomainManager
{ {
return searchdomains.ContainsKey(name); return searchdomains.ContainsKey(name);
} }
// Cleanup procedure
private async Task Cleanup()
{
try
{
if (_options.Cache.StoreEmbeddingCache)
{
var stopwatch = Stopwatch.StartNew();
await CacheHelper.UpdateEmbeddingStore(embeddingCache, _options);
stopwatch.Stop();
_logger.LogInformation("UpdateEmbeddingStore completed in {ElapsedMilliseconds} ms", stopwatch.ElapsedMilliseconds);
}
_logger.LogInformation("SearchdomainManager cleanup completed");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during SearchdomainManager cleanup");
}
}
public void Dispose()
{
Dispose(true).Wait();
GC.SuppressFinalize(this);
}
protected virtual async Task Dispose(bool disposing)
{
if (!disposed && disposing)
{
await Cleanup();
disposed = true;
}
}
} }

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -12,21 +12,22 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AdaptiveExpressions" Version="4.23.0" /> <PackageReference Include="AdaptiveExpressions" Version="4.23.1" />
<PackageReference Include="ElmahCore" Version="2.1.2" /> <PackageReference Include="ElmahCore" Version="2.1.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.1" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.3" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.2" />
<PackageReference Include="MySql.Data" Version="9.2.0" /> <PackageReference Include="MySql.Data" Version="9.6.0" />
<PackageReference Include="Npgsql" Version="9.0.3" /> <PackageReference Include="Npgsql" Version="10.0.1" />
<PackageReference Include="OllamaSharp" Version="5.2.2" /> <PackageReference Include="OllamaSharp" Version="5.2.2" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="9.0.3" /> <PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.2" />
<PackageReference Include="System.Data.SqlClient" Version="4.9.0" /> <PackageReference Include="System.Data.SqlClient" Version="4.9.0" />
<PackageReference Include="System.Data.Sqlite" Version="1.0.119" /> <PackageReference Include="System.Data.Sqlite" Version="2.0.2" />
<PackageReference Include="System.Numerics.Tensors" Version="9.0.3" /> <PackageReference Include="System.Numerics.Tensors" Version="10.0.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -91,8 +91,8 @@ async function generateCriticalCSSForViews() {
forceExclude: ['.btn'], // Otherwise buttons end up colorless and .btn overrides other classes like .btn-warning, etc. - so it has to be force-excluded here and re-added later forceExclude: ['.btn'], // Otherwise buttons end up colorless and .btn overrides other classes like .btn-warning, etc. - so it has to be force-excluded here and re-added later
forceInclude: [ forceInclude: [
'[data-bs-theme="dark"]', '[data-bs-theme="dark"] body', '[data-bs-theme="dark"] .navbar', '[data-bs-theme="dark"] .card', '[data-bs-theme="dark"] .btn', '[data-bs-theme="dark"]', '[data-bs-theme="dark"] body', '[data-bs-theme="dark"] .navbar', '[data-bs-theme="dark"] .card', '[data-bs-theme="dark"] .btn',
'.navbar',
'.col-md-4', '.col-md-4',
'.navbar', '.ms-auto', '.dropdown', '.dropdown-menu',
'.visually-hidden', // visually hidden headings '.visually-hidden', // visually hidden headings
'.bi-info-circle-fill', '.text-info', // info icon '.bi-info-circle-fill', '.text-info', // info icon
'.container', '.col-md-6', '.row', '.g-4', '.row>*', '.container', '.col-md-6', '.row', '.g-4', '.row>*',

View File

@@ -1,5 +1,5 @@
# How to use CriticalCSS # How to use CriticalCSS
1. Install it here 1. Install the dependencies from here
```bash ```bash
npm i -D critical npm i -D critical
npm install puppeteer npm install puppeteer
@@ -8,4 +8,3 @@ npm install puppeteer
```bash ```bash
node CriticalCSSGenerator.js node CriticalCSSGenerator.js
``` ```
3. Move the `.css` files from the current directory to the `CriticalCSS/` folder (overwrite existing files)

View File

@@ -52,7 +52,9 @@
<header> <header>
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light border-bottom box-shadow mb-3"> <nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light border-bottom box-shadow mb-3">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">embeddingsearch</a> <a class="navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">
<img fetchpriority="high" alt="Logo" src="/logo.png" width="40" height="40" style="width: 40px; height: 40px;">
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent" <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation"> aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
@@ -69,17 +71,20 @@
</li> </li>
@if (User.IsInRole("Admin") || User.IsInRole("Swagger")) @if (User.IsInRole("Admin") || User.IsInRole("Swagger"))
{ {
<li class="nav-item"> <li class="nav-item dropdown">
<a class="nav-link text" href="/swagger/index.html?ReturnUrl=@(currentUrl)">@T["Swagger"]</a> <a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
</li> @T["Tools"]
} </a>
<div class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
<a class="dropdown-item" href="/swagger/index.html?ReturnUrl=@(currentUrl)">@T["Swagger"]</a>
@if (User.IsInRole("Admin")) @if (User.IsInRole("Admin"))
{ {
<li class="nav-item"> <a class="dropdown-item" href="/elmah?ReturnUrl=@(currentUrl)">@T["Elmah"]</a>
<a class="nav-link text" href="/elmah?ReturnUrl=@(currentUrl)">@T["Elmah"]</a> }
</div>
</li> </li>
} }
<li class="nav-item"> <li class="nav-item ms-auto">
<a class="nav-link text" asp-area="" asp-controller="Account" asp-action="Logout">@T["Logout"]</a> <a class="nav-link text" asp-area="" asp-controller="Account" asp-action="Logout">@T["Logout"]</a>
</li> </li>
} }

View File

@@ -15,12 +15,12 @@
"Embeddingsearch": { "Embeddingsearch": {
"ConnectionStrings": { "ConnectionStrings": {
"SQL": "server=localhost;database=embeddingsearch;uid=embeddingsearch;pwd=somepassword!;" "SQL": "server=localhost;database=embeddingsearch;uid=embeddingsearch;pwd=somepassword!;",
"Cache": "Data Source=embeddings.db;Mode=ReadWriteCreate;Cache=Shared"
}, },
"Elmah": { "Elmah": {
"LogPath": "~/logs" "LogPath": "~/logs"
}, },
"EmbeddingCacheMaxCount": 10000000,
"AiProviders": { "AiProviders": {
"ollama": { "ollama": {
"handler": "ollama", "handler": "ollama",
@@ -46,6 +46,11 @@
] ]
}, },
"ApiKeys": ["Some UUID here", "Another UUID here"], "ApiKeys": ["Some UUID here", "Another UUID here"],
"UseHttpsRedirection": true "UseHttpsRedirection": true,
"Cache": {
"CacheTopN": 100000,
"StoreEmbeddingCache": true,
"StoreTopN": 20000
}
} }
} }

BIN
src/Server/wwwroot/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>