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:
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
|
||||
<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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user