host: composition root, health/ready/status, /rank and /webhook, auth handlers, MudBlazor status page, Dockerfile, CI pipelines

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 11:33:56 +02:00
co-authored by Claude Fable 5.1
parent 811696da90
commit af58249bc5
18 changed files with 640 additions and 19 deletions
+30
View File
@@ -0,0 +1,30 @@
when:
- event: push
branch: main
depends_on:
- test
steps:
- name: build-image
image: gcr.io/kaniko-project/executor:v1.23.2-debug
environment:
GITEA_TOKEN:
from_secret: registry_token
commands:
- mkdir -p /kaniko/.docker
- REGISTRY=$${CI_FORGE_URL#*://}
- IMAGE="$$REGISTRY/$$CI_REPO"
- AUTH=$(printf "%s" "$CI_REPO_OWNER:$GITEA_TOKEN" | base64 -w0)
- printf '{"auths":{"%s":{"auth":"%s"}}}' "$$REGISTRY" "$AUTH" > /kaniko/.docker/config.json
- BUILD_TAG="build.$(date +%s)"
- >-
/kaniko/executor
--context=.
--dockerfile=Dockerfile
--destination=$$IMAGE:${CI_COMMIT_SHA}
--destination=$$IMAGE:${CI_COMMIT_SHA:0:7}
--destination=$$IMAGE:$${BUILD_TAG}
--destination=$$IMAGE:main
--snapshot-mode=redo
--use-new-run
+10
View File
@@ -0,0 +1,10 @@
when:
- event: [pull_request, push]
steps:
- name: test
image: mcr.microsoft.com/dotnet/sdk:10.0
commands:
- dotnet restore Wiki.slnx
- dotnet build Wiki.slnx --no-restore -c Release
- dotnet test Wiki.slnx --no-build -c Release --logger "console;verbosity=minimal"
+33
View File
@@ -0,0 +1,33 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY Wiki.slnx Directory.Build.props ./
COPY src/Wiki.Core/Wiki.Core.csproj src/Wiki.Core/
COPY src/Wiki.Application/Wiki.Application.csproj src/Wiki.Application/
COPY src/Wiki.Adapters.Gitea/Wiki.Adapters.Gitea.csproj src/Wiki.Adapters.Gitea/
COPY src/Wiki.Adapters.LlamaCpp/Wiki.Adapters.LlamaCpp.csproj src/Wiki.Adapters.LlamaCpp/
COPY src/Wiki.Adapters.TypeSafe/Wiki.Adapters.TypeSafe.csproj src/Wiki.Adapters.TypeSafe/
COPY src/Wiki.Adapters.FileIndex/Wiki.Adapters.FileIndex.csproj src/Wiki.Adapters.FileIndex/
COPY src/Wiki.Adapters.Anthropic/Wiki.Adapters.Anthropic.csproj src/Wiki.Adapters.Anthropic/
COPY src/Wiki.Host/Wiki.Host.csproj src/Wiki.Host/
RUN dotnet restore src/Wiki.Host/Wiki.Host.csproj
COPY . .
# No --no-restore: publishing a Blazor Web App with it skips the static-asset
# setup and blazor.web.js never lands in the output (dotnet/aspnetcore#65291).
RUN dotnet publish src/Wiki.Host/Wiki.Host.csproj -c Release -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish ./
EXPOSE 8080
ENV ASPNETCORE_URLS=http://0.0.0.0:8080 \
DOTNET_RUNNING_IN_CONTAINER=true \
DOTNET_NOLOGO=true
# Index snapshots and the catalog live here; a mounted volume in the cluster.
RUN mkdir -p /data
VOLUME ["/data"]
ENTRYPOINT ["dotnet", "Wiki.Host.dll"]
+27
View File
@@ -0,0 +1,27 @@
# Wiki
Web interface and agent API for the Notes vault (`k.renner/notes`), running in the homelab
cluster. Design: `kubernetes/PROPOSAL-wiki-web-api.md` in the homelab repository.
## Layout
```
src/Wiki.Core domain types, ports, vault parsing — references nothing
src/Wiki.Application use cases (sync, rank, status, …) — depends on Core only
src/Wiki.Adapters.* one project per external thing, each references Core only
src/Wiki.Host ASP.NET Core API + MudBlazor UI, composition root
test/Wiki.Contracts one abstract suite per port; every adapter passes its suite
```
Adapters are selected in `appsettings.json` under `Wiki:`; secrets come from environment
variables (`Wiki__NoteStore__Gitea__Token`, `Wiki__Webhook__Secret`, `Auth__ApiKeys__<name>`).
## Run
```
dotnet test Wiki.slnx
dotnet run --project src/Wiki.Host
```
Endpoints: `/health` (liveness), `/ready` (readiness), `/api/v1/status`, `POST /rank`,
`POST /webhook`.
+24
View File
@@ -0,0 +1,24 @@
using Wiki.Application.Status;
using Wiki.Application.Sync;
namespace Wiki.Host.Api;
/// <summary>/api/v1: the read API grows here phase by phase. Phase 1 exposes status and a manual sync.</summary>
public static class ApiV1
{
public static void MapApiV1(this WebApplication app)
{
var v1 = app.MapGroup("/api/v1");
v1.MapGet("/status", async (StatusQuery status, CancellationToken ct) =>
Results.Ok(await status.GetAsync(includeAdapterChecks: true, ct))).AllowAnonymous();
v1.MapPost("/sync", async (SyncCoordinator sync, bool? force, CancellationToken ct) =>
{
var summary = await sync.RunAsync(force ?? false, ct);
return summary is null
? Results.Json(new { error = sync.LastError }, statusCode: StatusCodes.Status502BadGateway)
: Results.Ok(summary);
}).RequireAuthorization(Auth.Policies.Write);
}
}
+22
View File
@@ -0,0 +1,22 @@
using Wiki.Application.Status;
namespace Wiki.Host.Api;
public static class HealthEndpoints
{
/// <summary>
/// /health: the process serves, always 200 (liveness).
/// /ready: catalog synced once and the active index loaded (readiness and startup).
/// Adapter checks belong to /api/v1/status and never gate readiness.
/// </summary>
public static void MapHealthEndpoints(this WebApplication app)
{
app.MapGet("/health", () => Results.Ok(new { ok = true })).AllowAnonymous();
app.MapGet("/ready", async (StatusQuery status, CancellationToken ct) =>
{
var ready = await status.IsReadyAsync(ct);
var body = await status.GetAsync(includeAdapterChecks: false, ct);
return ready ? Results.Ok(body) : Results.Json(body, statusCode: StatusCodes.Status503ServiceUnavailable);
}).AllowAnonymous();
}
}
+77
View File
@@ -0,0 +1,77 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Wiki.Application.Retrieval;
using Wiki.Application.Status;
using Wiki.Core.Ports;
using Wiki.Host.Composition;
namespace Wiki.Host.Api;
public sealed record RankRequest(string? Query, string? Question, int? K, string? Model);
/// <summary>
/// The endpoints the Python wiki-index served, kept shape-compatible so bin/wiki_rank.py works
/// unchanged during the migration: POST /rank {query, k} → {rows: [{slug, title, score}], index},
/// POST /webhook (Gitea push, HMAC-SHA256 in X-Gitea-Signature).
/// </summary>
public static class IndexEndpoints
{
public static void MapIndexEndpoints(this WebApplication app)
{
app.MapPost("/rank", RankAsync).AllowAnonymous();
app.MapPost("/api/v1/rank", RankAsync).AllowAnonymous();
app.MapPost("/webhook", async (HttpRequest request, IConfiguration configuration, WikiOptions options, SyncBackgroundService sync, CancellationToken ct) =>
{
var secret = configuration["Wiki:Webhook:Secret"];
if (string.IsNullOrEmpty(secret))
return Results.Json(new { error = "webhook secret not configured" }, statusCode: StatusCodes.Status503ServiceUnavailable);
using var ms = new MemoryStream();
await request.Body.CopyToAsync(ms, ct);
var body = ms.ToArray();
var expected = Convert.ToHexStringLower(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), body));
var presented = (request.Headers["X-Gitea-Signature"].FirstOrDefault() ?? string.Empty).Trim();
if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(presented)))
return Results.Json(new { error = "bad signature" }, statusCode: StatusCodes.Status403Forbidden);
string? @ref = null;
try
{
using var doc = JsonDocument.Parse(body.Length == 0 ? "{}"u8.ToArray() : body);
if (doc.RootElement.TryGetProperty("ref", out var r)) @ref = r.GetString();
}
catch (JsonException)
{
return Results.BadRequest(new { error = "bad json" });
}
if (!string.IsNullOrEmpty(@ref) && @ref != $"refs/heads/{options.NoteStore.Gitea.Branch}")
return Results.Ok(new { queued = false, @ref });
sync.Trigger();
return Results.Ok(new { queued = true });
}).AllowAnonymous();
}
private static async Task<IResult> RankAsync(RankRequest body, RankUseCase rank, StatusQuery status, IServiceProvider services, IEmbeddingModel active, CancellationToken ct)
{
var question = (body.Query ?? body.Question ?? string.Empty).Trim();
if (question.Length == 0) return Results.BadRequest(new { error = "query required" });
var model = active;
if (!string.IsNullOrEmpty(body.Model))
{
model = services.GetKeyedService<IEmbeddingModel>(body.Model);
if (model is null) return Results.BadRequest(new { error = $"unknown model '{body.Model}'" });
}
try
{
var rows = await rank.RankAsync(model, question, body.K ?? 60, ct);
var index = await status.GetAsync(includeAdapterChecks: false, ct);
return Results.Ok(new { rows = rows.Select(r => new { r.Slug, r.Title, r.Score }), model = model.ModelId, index });
}
catch (HttpRequestException e)
{
return Results.Json(new { error = e.Message }, statusCode: StatusCodes.Status502BadGateway);
}
}
}
@@ -0,0 +1,54 @@
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace Wiki.Host.Auth;
/// <summary>
/// Machine callers present `X-Api-Key: <key>` or `Authorization: Bearer <key>`. Keys are named,
/// so a write carries the identity behind it: Auth:ApiKeys:{name} = key. Fail closed: no keys
/// configured means no key authenticates.
/// </summary>
public sealed class ApiKeyAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IConfiguration configuration)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
public const string SchemeName = "ApiKey";
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var presented = Request.Headers["X-Api-Key"].FirstOrDefault();
if (presented is null)
{
var authorization = Request.Headers.Authorization.FirstOrDefault();
if (authorization?.StartsWith("Bearer ", StringComparison.Ordinal) == true)
presented = authorization["Bearer ".Length..];
}
if (presented is null)
return Task.FromResult(AuthenticateResult.NoResult());
var presentedBytes = Encoding.UTF8.GetBytes(presented);
foreach (var child in configuration.GetSection("Auth:ApiKeys").GetChildren())
{
var configured = child.Value;
if (string.IsNullOrEmpty(configured)) continue;
if (CryptographicOperations.FixedTimeEquals(presentedBytes, Encoding.UTF8.GetBytes(configured)))
{
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, child.Key), new Claim(Claims.Kind, "machine")], Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name)));
}
}
return Task.FromResult(AuthenticateResult.Fail("invalid API key"));
}
}
public static class Policies
{
public const string Write = "write";
}
@@ -0,0 +1,71 @@
using System.Net;
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace Wiki.Host.Auth;
/// <summary>
/// Humans arrive through the Authentik proxy outpost, which sets X-Authentik-Username after
/// validating the session. The header is trusted only from the peers listed in
/// Auth:ReverseProxy:TrustedProxies; with none configured the scheme authenticates nobody.
/// </summary>
public sealed class ProxyHeaderAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IConfiguration configuration)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
public const string SchemeName = "ProxyHeader";
public const string DefaultHeaderName = "X-Authentik-Username";
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var headerName = configuration["Auth:ReverseProxy:HeaderName"];
if (string.IsNullOrWhiteSpace(headerName)) headerName = DefaultHeaderName;
var user = Request.Headers[headerName].FirstOrDefault();
if (string.IsNullOrWhiteSpace(user))
return Task.FromResult(AuthenticateResult.NoResult());
var peer = Context.Connection.RemoteIpAddress;
if (peer is null || !IsTrustedProxy(peer))
{
Logger.LogWarning("ignoring {Header} from untrusted peer {Peer}", headerName, peer);
return Task.FromResult(AuthenticateResult.NoResult());
}
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, user), new Claim(Claims.Kind, "human")], Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name)));
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
}
private bool IsTrustedProxy(IPAddress peer)
{
if (peer.IsIPv4MappedToIPv6) peer = peer.MapToIPv4();
var trusted = configuration.GetSection("Auth:ReverseProxy:TrustedProxies").Get<string[]>() ?? [];
foreach (var entry in trusted)
{
if (IPAddress.TryParse(entry, out var single))
{
if (single.Equals(peer)) return true;
continue;
}
if (IPNetwork.TryParse(entry, out var network) && network.Contains(peer))
return true;
}
return false;
}
}
public static class Claims
{
public const string Kind = "wiki:kind";
}
+3 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en">
<head>
@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<ResourcePreloader />
<link rel="stylesheet" href="@Assets["_content/MudBlazor/MudBlazor.min.css"]" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["Wiki.Host.styles.css"]" />
<ImportMap />
@@ -16,6 +17,7 @@
<Routes />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
</body>
</html>
@@ -1,6 +1,22 @@
@inherits LayoutComponentBase
@inherits LayoutComponentBase
@Body
<MudThemeProvider />
<MudPopoverProvider />
<MudSnackbarProvider />
<MudDialogProvider />
<MudLayout>
<MudAppBar Elevation="1">
<MudText Typo="Typo.h6">Wiki</MudText>
<MudSpacer />
<MudLink Href="/" Color="Color.Inherit" Class="mx-2">Status</MudLink>
</MudAppBar>
<MudMainContent>
<MudContainer MaxWidth="MaxWidth.Large" Class="py-6">
@Body
</MudContainer>
</MudMainContent>
</MudLayout>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
+75 -4
View File
@@ -1,7 +1,78 @@
@page "/"
@page "/"
@rendermode InteractiveServer
@using Wiki.Application.Status
@inject StatusQuery Status
<PageTitle>Home</PageTitle>
<PageTitle>Wiki status</PageTitle>
<h1>Hello, world!</h1>
<MudText Typo="Typo.h4" GutterBottom="true">Status</MudText>
Welcome to your new app.
@if (_status is null)
{
<MudProgressLinear Indeterminate="true" />
}
else
{
<MudGrid>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4">
<MudText Typo="Typo.subtitle1">Index</MudText>
<MudSimpleTable Dense="true">
<tbody>
<tr><td>Ready</td><td>@(_status.Ready ? "yes" : "no")</td></tr>
<tr><td>Commit</td><td><code>@(_status.Commit is { Length: > 8 } c ? c[..8] : _status.Commit)</code></td></tr>
<tr><td>Synced</td><td>@(_status.SyncedAt?.ToString("u") ?? "never") (@(_status.AgeSeconds?.ToString("0") ?? "-") s ago)</td></tr>
<tr><td>Syncing</td><td>@(_status.Syncing ? "yes" : "no")</td></tr>
<tr><td>Notes</td><td>@_status.Notes</td></tr>
<tr><td>Raw queue</td><td>@_status.RawQueue</td></tr>
<tr><td>Outputs</td><td>@_status.Outputs</td></tr>
@if (_status.LastError is not null)
{
<tr><td>Last error</td><td><MudText Color="Color.Error">@_status.LastError</MudText></td></tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4">
<MudText Typo="Typo.subtitle1">Embedding models</MudText>
<MudSimpleTable Dense="true">
<tbody>
@foreach (var i in _status.Indexes)
{
<tr><td>@i.ModelId @(i.Active ? "(active)" : "")</td><td>@i.Notes vectors</td></tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
</MudItem>
<MudItem xs="12">
<MudPaper Class="pa-4">
<MudText Typo="Typo.subtitle1">Adapters</MudText>
<MudSimpleTable Dense="true">
<tbody>
@foreach (var a in _status.Adapters)
{
<tr>
<td>@a.Adapter</td>
<td><MudChip T="string" Color="@(a.Ok ? Color.Success : Color.Error)" Size="Size.Small">@(a.Ok ? "ok" : "failing")</MudChip></td>
<td>@a.Latency.TotalMilliseconds.ToString("0") ms</td>
<td>@a.Detail</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
</MudItem>
</MudGrid>
}
@code {
private ServiceStatus? _status;
protected override async Task OnInitializedAsync()
{
_status = await Status.GetAsync(includeAdapterChecks: true, CancellationToken.None);
}
}
+2 -1
View File
@@ -1,4 +1,4 @@
@using System.Net.Http
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@@ -6,6 +6,7 @@
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using MudBlazor
@using Wiki.Host
@using Wiki.Host.Components
@using Wiki.Host.Components.Layout
@@ -0,0 +1,32 @@
using Wiki.Application.Sync;
namespace Wiki.Host.Composition;
/// <summary>Syncs at start, on every webhook trigger, and every interval; retries sooner after a failure.</summary>
public sealed class SyncBackgroundService(SyncCoordinator coordinator, WikiOptions options, ILogger<SyncBackgroundService> log) : BackgroundService
{
private readonly SemaphoreSlim _wake = new(0);
public void Trigger() => _wake.Release();
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var summary = await coordinator.RunAsync(force: false, stoppingToken);
var wait = summary is null ? options.Sync.RetrySeconds : options.Sync.IntervalSeconds;
if (summary is null)
log.LogWarning("sync failed; retrying in {Seconds}s", wait);
try
{
await _wake.WaitAsync(TimeSpan.FromSeconds(wait), stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
// drain extra triggers that arrived during the run; one sync covers them
while (_wake.CurrentCount > 0) await _wake.WaitAsync(0, stoppingToken);
}
}
}
+55
View File
@@ -0,0 +1,55 @@
namespace Wiki.Host.Composition;
/// <summary>The `Wiki` configuration section: which adapters exist and which one is active. Secrets are read separately from configuration keys, never bound here.</summary>
public sealed class WikiOptions
{
public NoteStoreOptions NoteStore { get; set; } = new();
public EmbeddingsOptions Embeddings { get; set; } = new();
public IndexOptions Index { get; set; } = new();
public SyncScheduleOptions Sync { get; set; } = new();
}
public sealed class NoteStoreOptions
{
public string Active { get; set; } = "gitea";
public GiteaStoreOptions Gitea { get; set; } = new();
}
public sealed class GiteaStoreOptions
{
public string BaseUrl { get; set; } = "https://gitea.renner.uno";
public string Repository { get; set; } = "k.renner/notes";
public string Branch { get; set; } = "main";
}
public sealed class EmbeddingsOptions
{
public string Active { get; set; } = "bge-m3";
public Dictionary<string, EmbeddingModelOptions> Models { get; set; } = new();
}
public sealed class EmbeddingModelOptions
{
public string Adapter { get; set; } = "LlamaCpp";
public string Url { get; set; } = string.Empty;
public string Model { get; set; } = string.Empty;
public int Dimension { get; set; } = 1024;
}
public sealed class IndexOptions
{
public string Active { get; set; } = "file";
public FileIndexStoreOptions File { get; set; } = new();
}
public sealed class FileIndexStoreOptions
{
public string Directory { get; set; } = "/data";
}
public sealed class SyncScheduleOptions
{
/// <summary>Reconcile interval; the webhook is the fast path.</summary>
public int IntervalSeconds { get; set; } = 1800;
public int RetrySeconds { get; set; } = 60;
}
+84 -10
View File
@@ -1,27 +1,101 @@
using Microsoft.AspNetCore.Authentication;
using MudBlazor.Services;
using Wiki.Adapters.FileIndex;
using Wiki.Adapters.Gitea;
using Wiki.Adapters.LlamaCpp;
using Wiki.Application.Retrieval;
using Wiki.Application.Status;
using Wiki.Application.Sync;
using Wiki.Core.Ports;
using Wiki.Host.Api;
using Wiki.Host.Auth;
using Wiki.Host.Components;
using Wiki.Host.Composition;
var builder = WebApplication.CreateBuilder(args);
var wiki = builder.Configuration.GetSection("Wiki").Get<WikiOptions>() ?? new WikiOptions();
builder.Services.AddSingleton(wiki);
builder.Services.AddSingleton(TimeProvider.System);
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
// ---- adapters, selected by configuration ------------------------------------------------
builder.Services.AddGiteaAdapters(o =>
{
o.BaseUrl = wiki.NoteStore.Gitea.BaseUrl;
o.Repository = wiki.NoteStore.Gitea.Repository;
o.Branch = wiki.NoteStore.Gitea.Branch;
o.Token = builder.Configuration["Wiki:NoteStore:Gitea:Token"] ?? string.Empty;
});
builder.Services.AddFileIndexAdapters(o => o.Directory = wiki.Index.File.Directory);
foreach (var (name, model) in wiki.Embeddings.Models)
{
switch (model.Adapter)
{
case "LlamaCpp":
builder.Services.AddLlamaCppEmbeddings(name, o =>
{
o.Url = model.Url;
o.Model = model.Model;
o.ModelId = name;
o.Dimension = model.Dimension;
o.BasicAuth = builder.Configuration[$"Wiki:Embeddings:Models:{name}:BasicAuth"];
});
break;
default:
throw new InvalidOperationException($"unknown embeddings adapter '{model.Adapter}' for model '{name}'");
}
}
builder.Services.AddSingleton<IReadOnlyList<IEmbeddingModel>>(sp =>
wiki.Embeddings.Models.Keys.Select(sp.GetRequiredKeyedService<IEmbeddingModel>).ToList());
builder.Services.AddSingleton<IEmbeddingModel>(sp => sp.GetRequiredKeyedService<IEmbeddingModel>(wiki.Embeddings.Active));
builder.Services.AddSingleton<IReadOnlyList<IAdapterHealth>>(sp =>
[
sp.GetRequiredService<INoteStore>(),
sp.GetRequiredService<IFactSink>(),
sp.GetRequiredService<IVectorIndex>(),
sp.GetRequiredService<IDocumentCatalog>(),
.. sp.GetRequiredService<IReadOnlyList<IEmbeddingModel>>(),
]);
// ---- use cases ---------------------------------------------------------------------------
builder.Services.AddSingleton(new SyncOptions());
builder.Services.AddSingleton<SyncUseCase>();
builder.Services.AddSingleton<SyncCoordinator>();
builder.Services.AddSingleton<RankUseCase>();
builder.Services.AddSingleton<StatusQuery>();
builder.Services.AddSingleton<SyncBackgroundService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<SyncBackgroundService>());
// ---- auth: humans arrive through the Authentik outpost, machines with an API key ---------
builder.Services.AddAuthentication(ProxyHeaderAuthenticationHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, ProxyHeaderAuthenticationHandler>(ProxyHeaderAuthenticationHandler.SchemeName, null)
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(ApiKeyAuthenticationHandler.SchemeName, null);
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(Policies.Write, p => p.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName).RequireAuthenticatedUser());
});
// ---- UI ----------------------------------------------------------------------------------
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddMudServices();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapHealthEndpoints();
app.MapIndexEndpoints();
app.MapApiV1();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
app.Run();
public partial class Program;
+4
View File
@@ -9,6 +9,10 @@
<ProjectReference Include="..\Wiki.Adapters.Anthropic\Wiki.Adapters.Anthropic.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MudBlazor" Version="9.10.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
+19 -1
View File
@@ -5,5 +5,23 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"Wiki": {
"NoteStore": {
"Active": "gitea",
"Gitea": { "BaseUrl": "https://gitea.renner.uno", "Repository": "k.renner/notes", "Branch": "main" }
},
"Embeddings": {
"Active": "bge-m3",
"Models": {
"bge-m3": { "Adapter": "LlamaCpp", "Url": "http://wiki-embed.ai.svc.cluster.local:8080", "Model": "bge-m3", "Dimension": 1024 }
}
},
"Index": { "Active": "file", "File": { "Directory": "/data" } },
"Sync": { "IntervalSeconds": 1800, "RetrySeconds": 60 }
},
"Auth": {
"ReverseProxy": { "HeaderName": "X-Authentik-Username", "TrustedProxies": [] },
"ApiKeys": {}
}
}