Compare commits
9 Commits
76c9913485
...
6d39540e8d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d39540e8d | ||
| 328615be97 | |||
|
|
20cbbfd06c | ||
| cfeefa385a | |||
| 49ecb06fb0 | |||
|
|
a15548ea77 | ||
| e2cfe56b49 | |||
|
|
9c306a0917 | ||
| 5f05aac909 |
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
1
.gitignore
vendored
@@ -20,3 +20,4 @@ src/Shared/obj
|
||||
src/Server/wwwroot/logs/*
|
||||
src/Server/Tools/CriticalCSS/node_modules
|
||||
src/Server/Tools/CriticalCSS/package*.json
|
||||
*.db*
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ElmahCore" Version="2.1.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.14" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.14.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.8" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.2" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="9.0.3" />
|
||||
<PackageReference Include="Python" Version="3.13.3" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.0" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.2" />
|
||||
<PackageReference Include="Python" Version="3.14.2" />
|
||||
<PackageReference Include="Pythonnet" Version="3.0.5" />
|
||||
</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
242
src/Server/Helper/CacheHelper.cs
Normal file
242
src/Server/Helper/CacheHelper.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
76
src/Server/Helper/SQLiteHelper.cs
Normal file
76
src/Server/Helper/SQLiteHelper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ public class SearchdomainHelper(ILogger<SearchdomainHelper> logger, DatabaseHelp
|
||||
|
||||
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();
|
||||
Buffer.BlockCopy(floatArray, 0, byteArray, 0, byteArray.Length);
|
||||
return byteArray;
|
||||
@@ -24,7 +24,7 @@ public class SearchdomainHelper(ILogger<SearchdomainHelper> logger, DatabaseHelp
|
||||
|
||||
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);
|
||||
return floatArray;
|
||||
}
|
||||
|
||||
65
src/Server/Migrations/SQLiteMigrations.cs
Normal file
65
src/Server/Migrations/SQLiteMigrations.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,9 @@ public class EmbeddingSearchOptions : ApiKeyOptions
|
||||
{
|
||||
public required ConnectionStringsOptions ConnectionStrings { get; set; }
|
||||
public ElmahOptions? Elmah { get; set; }
|
||||
public required long EmbeddingCacheMaxCount { get; set; }
|
||||
public required Dictionary<string, AiProvider> AiProviders { get; set; }
|
||||
public required SimpleAuthOptions SimpleAuth { get; set; }
|
||||
public required CacheOptions Cache { get; set; }
|
||||
public required bool UseHttpsRedirection { get; set; }
|
||||
}
|
||||
|
||||
@@ -38,4 +38,12 @@ public class SimpleUser
|
||||
public class ConnectionStringsOptions
|
||||
{
|
||||
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; }
|
||||
}
|
||||
109
src/Server/Models/SQLHelper.cs
Normal file
109
src/Server/Models/SQLHelper.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,14 @@ using Server.Helper;
|
||||
using Server.Models;
|
||||
using Server.Services;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Reflection;
|
||||
using System.Configuration;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Microsoft.OpenApi;
|
||||
using Shared.Models;
|
||||
using Microsoft.AspNetCore.ResponseCompression;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Server.Migrations;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
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);
|
||||
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
|
||||
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
|
||||
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
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
builder.Services.AddOpenApi(options =>
|
||||
{
|
||||
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
|
||||
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
|
||||
c.IncludeXmlComments(xmlPath);
|
||||
if (configuration.ApiKeys is not null)
|
||||
options.AddDocumentTransformer((document, context, _) =>
|
||||
{
|
||||
c.AddSecurityDefinition("ApiKey", new OpenApiSecurityScheme
|
||||
{
|
||||
Description = "ApiKey must appear in header",
|
||||
Type = SecuritySchemeType.ApiKey,
|
||||
Name = "X-API-KEY",
|
||||
In = ParameterLocation.Header,
|
||||
Scheme = "ApiKeyScheme"
|
||||
});
|
||||
var key = new OpenApiSecurityScheme()
|
||||
{
|
||||
Reference = new OpenApiReference
|
||||
if (configuration.ApiKeys is null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
document.Components ??= new();
|
||||
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
|
||||
|
||||
document.Components.SecuritySchemes["ApiKey"] =
|
||||
new OpenApiSecurityScheme
|
||||
{
|
||||
Type = ReferenceType.SecurityScheme,
|
||||
Id = "ApiKey"
|
||||
},
|
||||
In = ParameterLocation.Header
|
||||
};
|
||||
var requirement = new OpenApiSecurityRequirement
|
||||
{
|
||||
{ key, []}
|
||||
};
|
||||
c.AddSecurityRequirement(requirement);
|
||||
}
|
||||
Type = SecuritySchemeType.ApiKey,
|
||||
Name = "X-API-KEY",
|
||||
In = ParameterLocation.Header,
|
||||
Description = "ApiKey must appear in header"
|
||||
};
|
||||
|
||||
document.Security ??= [];
|
||||
|
||||
// Apply globally
|
||||
document.Security?.Add(
|
||||
new OpenApiSecurityRequirement
|
||||
{
|
||||
[new OpenApiSecuritySchemeReference("ApiKey", document)] = []
|
||||
}
|
||||
);
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
});
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.ReadFrom.Configuration(builder.Configuration)
|
||||
@@ -232,13 +242,16 @@ app.Use(async (context, next) =>
|
||||
await next();
|
||||
});
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI(options =>
|
||||
{
|
||||
options.SwaggerEndpoint("/openapi/v1.json", "API v1");
|
||||
options.RoutePrefix = "swagger";
|
||||
options.EnablePersistAuthorization();
|
||||
options.InjectStylesheet("/swagger-ui/custom.css");
|
||||
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.
|
||||
|
||||
if (configuration.ApiKeys is not null)
|
||||
|
||||
@@ -9,10 +9,11 @@ using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Server.Models;
|
||||
using Shared;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Server;
|
||||
|
||||
public class SearchdomainManager
|
||||
public class SearchdomainManager : IDisposable
|
||||
{
|
||||
private Dictionary<string, Searchdomain> searchdomains = [];
|
||||
private readonly ILogger<SearchdomainManager> _logger;
|
||||
@@ -24,6 +25,7 @@ public class SearchdomainManager
|
||||
public SQLHelper helper;
|
||||
public EnumerableLruCache<string, Dictionary<string, float[]>> embeddingCache;
|
||||
public long EmbeddingCacheMaxCount;
|
||||
private bool disposed = false;
|
||||
|
||||
public SearchdomainManager(ILogger<SearchdomainManager> logger, IOptions<EmbeddingSearchOptions> options, AIProvider aIProvider, DatabaseHelper databaseHelper)
|
||||
{
|
||||
@@ -31,8 +33,17 @@ public class SearchdomainManager
|
||||
_options = options.Value;
|
||||
this.aIProvider = aIProvider;
|
||||
_databaseHelper = databaseHelper;
|
||||
EmbeddingCacheMaxCount = _options.EmbeddingCacheMaxCount;
|
||||
embeddingCache = new((int)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);
|
||||
}
|
||||
connectionString = _options.ConnectionStrings.SQL;
|
||||
connection = new MySqlConnection(connectionString);
|
||||
connection.Open();
|
||||
@@ -80,7 +91,7 @@ public class SearchdomainManager
|
||||
{
|
||||
results.Add(reader.GetString(0));
|
||||
}
|
||||
return results;
|
||||
return results;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -127,4 +138,39 @@ public class SearchdomainManager
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
@@ -12,21 +12,22 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AdaptiveExpressions" Version="4.23.0" />
|
||||
<PackageReference Include="ElmahCore" Version="2.1.2" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.3" />
|
||||
<PackageReference Include="MySql.Data" Version="9.2.0" />
|
||||
<PackageReference Include="Npgsql" Version="9.0.3" />
|
||||
<PackageReference Include="AdaptiveExpressions" Version="4.23.1" />
|
||||
<PackageReference Include="ElmahCore" Version="2.1.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
|
||||
<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="Swashbuckle.AspNetCore" Version="10.1.0" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.2" />
|
||||
<PackageReference Include="MySql.Data" Version="9.6.0" />
|
||||
<PackageReference Include="Npgsql" Version="10.0.1" />
|
||||
<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.Sqlite" Version="1.0.119" />
|
||||
<PackageReference Include="System.Numerics.Tensors" Version="9.0.3" />
|
||||
<PackageReference Include="System.Data.Sqlite" Version="2.0.2" />
|
||||
<PackageReference Include="System.Numerics.Tensors" Version="10.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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
|
||||
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',
|
||||
'.navbar',
|
||||
'.col-md-4',
|
||||
'.navbar', '.ms-auto', '.dropdown', '.dropdown-menu',
|
||||
'.visually-hidden', // visually hidden headings
|
||||
'.bi-info-circle-fill', '.text-info', // info icon
|
||||
'.container', '.col-md-6', '.row', '.g-4', '.row>*',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# How to use CriticalCSS
|
||||
1. Install it here
|
||||
1. Install the dependencies from here
|
||||
```bash
|
||||
npm i -D critical
|
||||
npm install puppeteer
|
||||
@@ -7,5 +7,4 @@ npm install puppeteer
|
||||
2. Run the css generator:
|
||||
```bash
|
||||
node CriticalCSSGenerator.js
|
||||
```
|
||||
3. Move the `.css` files from the current directory to the `CriticalCSS/` folder (overwrite existing files)
|
||||
```
|
||||
@@ -52,7 +52,9 @@
|
||||
<header>
|
||||
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light border-bottom box-shadow mb-3">
|
||||
<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"
|
||||
aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
@@ -69,17 +71,20 @@
|
||||
</li>
|
||||
@if (User.IsInRole("Admin") || User.IsInRole("Swagger"))
|
||||
{
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text" href="/swagger/index.html?ReturnUrl=@(currentUrl)">@T["Swagger"]</a>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
@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"))
|
||||
{
|
||||
<a class="dropdown-item" href="/elmah?ReturnUrl=@(currentUrl)">@T["Elmah"]</a>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@if (User.IsInRole("Admin"))
|
||||
{
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text" href="/elmah?ReturnUrl=@(currentUrl)">@T["Elmah"]</a>
|
||||
</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>
|
||||
</li>
|
||||
}
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
|
||||
"Embeddingsearch": {
|
||||
"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": {
|
||||
"LogPath": "~/logs"
|
||||
},
|
||||
"EmbeddingCacheMaxCount": 10000000,
|
||||
"AiProviders": {
|
||||
"ollama": {
|
||||
"handler": "ollama",
|
||||
@@ -46,6 +46,11 @@
|
||||
]
|
||||
},
|
||||
"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
BIN
src/Server/wwwroot/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.1 KiB |
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
Reference in New Issue
Block a user