Monorepo with centralized build props, npm workspaces, LlamaSharp AI, SQLite/SQLCipher storage, Svelte frontend, and unified smoke tests. Co-Authored-By: Oz <oz-agent@warp.dev>
265 lines
6.8 KiB
C#
265 lines
6.8 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Journal.AI;
|
|
using Journal.Core;
|
|
using Microsoft.Extensions.FileProviders;
|
|
|
|
var gatewayJsonOptions = new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
|
};
|
|
|
|
var repoRoot = ResolveRepoRoot();
|
|
Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", repoRoot);
|
|
var webDistPath = ResolveWebDist(repoRoot);
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
builder.Services.AddFragmentServices();
|
|
builder.Services.AddLlamaSharpServices();
|
|
builder.Services.AddSingleton<Entry>();
|
|
builder.Services.AddSingleton(new SidecarRootState(repoRoot));
|
|
builder.Services.AddSingleton(new WebUiState(webDistPath));
|
|
|
|
builder.Services.AddCors(options =>
|
|
{
|
|
options.AddPolicy("GatewayCors", policy =>
|
|
{
|
|
policy.AllowAnyOrigin()
|
|
.AllowAnyHeader()
|
|
.AllowAnyMethod();
|
|
});
|
|
});
|
|
|
|
builder.Services.ConfigureHttpJsonOptions(options =>
|
|
{
|
|
options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
|
options.SerializerOptions.PropertyNameCaseInsensitive = true;
|
|
});
|
|
|
|
var app = builder.Build();
|
|
|
|
app.UseCors("GatewayCors");
|
|
|
|
app.MapGet("/api/health", () => Results.Ok(new
|
|
{
|
|
ok = true,
|
|
service = "Journal.WebGateway"
|
|
}));
|
|
|
|
app.MapGet("/api/web/status", (WebUiState webUiState) => Results.Ok(new
|
|
{
|
|
distPath = webUiState.DistPath,
|
|
exists = webUiState.Exists
|
|
}));
|
|
|
|
app.MapPost("/api/command", async (CommandEnvelope? command, Entry entry) =>
|
|
{
|
|
if (command is null || string.IsNullOrWhiteSpace(command.Action))
|
|
{
|
|
return Results.Content(ErrorResponse("Missing action"), "application/json");
|
|
}
|
|
|
|
var inputJson = JsonSerializer.Serialize(command, gatewayJsonOptions);
|
|
var responseJson = await entry.HandleCommandAsync(inputJson);
|
|
return Results.Content(responseJson, "application/json");
|
|
});
|
|
|
|
app.MapGet("/api/sidecar/root", (SidecarRootState rootState) =>
|
|
{
|
|
var (root, isCustom) = rootState.Get();
|
|
return Results.Ok(new
|
|
{
|
|
root,
|
|
isCustom
|
|
});
|
|
});
|
|
|
|
app.MapPost("/api/sidecar/root", (SetSidecarRootRequest? request, SidecarRootState rootState) =>
|
|
{
|
|
var path = request?.Path ?? "";
|
|
if (!string.IsNullOrWhiteSpace(path) && !Directory.Exists(path))
|
|
{
|
|
return Results.BadRequest($"Directory '{path}' does not exist.");
|
|
}
|
|
|
|
rootState.Set(path);
|
|
var (root, isCustom) = rootState.Get();
|
|
Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", root);
|
|
return Results.Ok(new
|
|
{
|
|
root,
|
|
isCustom
|
|
});
|
|
});
|
|
|
|
if (Directory.Exists(webDistPath) && File.Exists(Path.Combine(webDistPath, "index.html")))
|
|
{
|
|
var fileProvider = new PhysicalFileProvider(webDistPath);
|
|
var indexPath = Path.Combine(webDistPath, "index.html");
|
|
|
|
app.UseDefaultFiles(new DefaultFilesOptions
|
|
{
|
|
FileProvider = fileProvider,
|
|
RequestPath = ""
|
|
});
|
|
|
|
app.UseStaticFiles(new StaticFileOptions
|
|
{
|
|
FileProvider = fileProvider,
|
|
RequestPath = ""
|
|
});
|
|
|
|
app.MapGet("/", async context =>
|
|
{
|
|
context.Response.ContentType = "text/html; charset=utf-8";
|
|
await context.Response.SendFileAsync(indexPath);
|
|
});
|
|
|
|
app.MapFallback(async context =>
|
|
{
|
|
if (context.Request.Path.StartsWithSegments("/api"))
|
|
{
|
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
|
return;
|
|
}
|
|
|
|
context.Response.ContentType = "text/html; charset=utf-8";
|
|
await context.Response.SendFileAsync(indexPath);
|
|
});
|
|
}
|
|
else
|
|
{
|
|
app.MapGet("/", () => Results.Ok(new
|
|
{
|
|
name = "Journal.WebGateway",
|
|
status = "ok",
|
|
uiAvailable = false,
|
|
message = "No built web UI found. Build Journal.App with ./scripts/publish-app.ps1 -Target web.",
|
|
expectedDist = webDistPath
|
|
}));
|
|
}
|
|
|
|
app.Run();
|
|
|
|
string ResolveRepoRoot()
|
|
{
|
|
var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_PROJECT_ROOT");
|
|
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
|
|
{
|
|
return Path.GetFullPath(fromEnv);
|
|
}
|
|
|
|
foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory })
|
|
{
|
|
var resolved = FindRepoRoot(start);
|
|
if (resolved is not null)
|
|
{
|
|
return resolved;
|
|
}
|
|
}
|
|
|
|
return Path.GetFullPath(Directory.GetCurrentDirectory());
|
|
}
|
|
|
|
string? FindRepoRoot(string start)
|
|
{
|
|
var cursor = Path.GetFullPath(start);
|
|
|
|
while (!string.IsNullOrWhiteSpace(cursor))
|
|
{
|
|
if (File.Exists(Path.Combine(cursor, "Journal.slnx")) ||
|
|
Directory.Exists(Path.Combine(cursor, "Journal.Sidecar")) ||
|
|
Directory.Exists(Path.Combine(cursor, "Journal.Core")))
|
|
{
|
|
return cursor;
|
|
}
|
|
|
|
var parent = Directory.GetParent(cursor);
|
|
if (parent is null || string.Equals(parent.FullName, cursor, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
cursor = parent.FullName;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
string ResolveWebDist(string repoRootPath)
|
|
{
|
|
var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_WEB_DIST");
|
|
if (!string.IsNullOrWhiteSpace(fromEnv))
|
|
{
|
|
return Path.GetFullPath(fromEnv);
|
|
}
|
|
|
|
var packagedWwwRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot");
|
|
if (Directory.Exists(packagedWwwRoot))
|
|
{
|
|
return packagedWwwRoot;
|
|
}
|
|
|
|
return Path.Combine(repoRootPath, "Journal.App", "build");
|
|
}
|
|
|
|
string ErrorResponse(string message)
|
|
=> JsonSerializer.Serialize(new { ok = false, error = message }, gatewayJsonOptions);
|
|
|
|
sealed class WebUiState(string distPath)
|
|
{
|
|
public string DistPath { get; } = distPath;
|
|
|
|
public bool Exists => Directory.Exists(DistPath) && File.Exists(Path.Combine(DistPath, "index.html"));
|
|
}
|
|
|
|
sealed class SidecarRootState(string autoRoot)
|
|
{
|
|
private readonly object _sync = new();
|
|
private readonly string _autoRoot = autoRoot;
|
|
private string _currentRoot = autoRoot;
|
|
private bool _isCustom;
|
|
|
|
public (string Root, bool IsCustom) Get()
|
|
{
|
|
lock (_sync)
|
|
{
|
|
return (_currentRoot, _isCustom);
|
|
}
|
|
}
|
|
|
|
public void Set(string? path)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
{
|
|
_currentRoot = _autoRoot;
|
|
_isCustom = false;
|
|
return;
|
|
}
|
|
|
|
_currentRoot = Path.GetFullPath(path.Trim());
|
|
_isCustom = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
sealed class SetSidecarRootRequest
|
|
{
|
|
public string? Path { get; set; }
|
|
}
|
|
|
|
sealed class CommandEnvelope
|
|
{
|
|
public string Action { get; set; } = "";
|
|
public string? CorrelationId { get; set; }
|
|
public string? Id { get; set; }
|
|
public string? Type { get; set; }
|
|
public string? Tag { get; set; }
|
|
public JsonElement? Payload { get; set; }
|
|
}
|