Add unified Stella Assistant: mascot + search + AI chat as one
Merge three disconnected help surfaces (Stella mascot, Ctrl+K search, Advisory AI chat) into one unified assistant. Mascot is the face, search is its memory, AI chat is its voice. Backend: - DB schema (060/061): tips, greetings, glossary, tours, user_state tables with 189 tips + 101 greetings seed data - REST API: GET tips/glossary/tours, GET/PUT user-state with longest-prefix route matching and locale fallback - Admin endpoints: CRUD for tips, glossary, tours (SetupAdmin policy) Frontend: - StellaAssistantService: unified mode management (tips/search/chat), API-backed tips with static fallback, i18n integration - Three-mode mascot component: tips, inline search, embedded chat - StellaGlossaryDirective: DB-backed tooltip annotations for domain terms - Admin tip editor: CRUD for tips/glossary/tours in Console Admin - Tour player: step-through guided tours with element highlighting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Stella Assistant API — DB-backed, locale-aware contextual help for the mascot.
|
||||
/// Serves tips, glossary, tours, and persists user state.
|
||||
/// </summary>
|
||||
public static class AssistantEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAssistantEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/stella-assistant")
|
||||
.WithTags("Stella Assistant")
|
||||
.RequireAuthorization(PlatformPolicies.PreferencesRead);
|
||||
|
||||
// ─── Tips ────────────────────────────────────────────────────────
|
||||
|
||||
group.MapGet("/tips", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
[FromQuery] string route,
|
||||
[FromQuery] string? locale,
|
||||
[FromQuery] string? contexts,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var effectiveLocale = locale ?? "en-US";
|
||||
var contextList = string.IsNullOrWhiteSpace(contexts)
|
||||
? Array.Empty<string>()
|
||||
: contexts.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
var result = await store.GetTipsAsync(route, effectiveLocale, contextList, tenantId, ct);
|
||||
return Results.Ok(result);
|
||||
})
|
||||
.WithName("GetAssistantTips")
|
||||
.WithSummary("Get contextual tips for a route and locale");
|
||||
|
||||
// ─── Glossary ────────────────────────────────────────────────────
|
||||
|
||||
group.MapGet("/glossary", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
[FromQuery] string? locale,
|
||||
[FromQuery] string? terms,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var effectiveLocale = locale ?? "en-US";
|
||||
var termList = string.IsNullOrWhiteSpace(terms)
|
||||
? null
|
||||
: terms.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
var result = await store.GetGlossaryAsync(effectiveLocale, tenantId, termList, ct);
|
||||
return Results.Ok(result);
|
||||
})
|
||||
.WithName("GetAssistantGlossary")
|
||||
.WithSummary("Get domain glossary terms for a locale");
|
||||
|
||||
// ─── User State ──────────────────────────────────────────────────
|
||||
|
||||
group.MapGet("/user-state", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var (userId, tenantId) = ResolveUserContext(httpContext);
|
||||
var state = await store.GetUserStateAsync(userId, tenantId, ct);
|
||||
return state is not null ? Results.Ok(state) : Results.Ok(new AssistantUserStateDto(
|
||||
Array.Empty<string>(), Array.Empty<string>(), new Dictionary<string, int>(), false));
|
||||
})
|
||||
.WithName("GetAssistantUserState")
|
||||
.WithSummary("Get user's assistant preferences and seen routes");
|
||||
|
||||
group.MapPut("/user-state", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
AssistantUserStateDto state,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var (userId, tenantId) = ResolveUserContext(httpContext);
|
||||
await store.UpsertUserStateAsync(userId, tenantId, state, ct);
|
||||
return Results.Ok();
|
||||
})
|
||||
.WithName("UpdateAssistantUserState")
|
||||
.WithSummary("Persist user's assistant preferences")
|
||||
.RequireAuthorization(PlatformPolicies.PreferencesWrite);
|
||||
|
||||
// ─── Tours ───────────────────────────────────────────────────────
|
||||
|
||||
group.MapGet("/tours", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
[FromQuery] string? locale,
|
||||
[FromQuery] string? tourKey,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var effectiveLocale = locale ?? "en-US";
|
||||
var result = await store.GetToursAsync(effectiveLocale, tenantId, tourKey, ct);
|
||||
return Results.Ok(result);
|
||||
})
|
||||
.WithName("GetAssistantTours")
|
||||
.WithSummary("Get guided tour definitions");
|
||||
|
||||
// ─── Admin CRUD ──────────────────────────────────────────────────
|
||||
|
||||
var admin = app.MapGroup("/api/v1/stella-assistant/admin")
|
||||
.WithTags("Stella Assistant Admin")
|
||||
.RequireAuthorization(PlatformPolicies.SetupAdmin);
|
||||
|
||||
admin.MapPost("/tips", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
UpsertAssistantTipRequest request,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var actor = ResolveUserId(httpContext);
|
||||
var id = await store.UpsertTipAsync(tenantId, request, actor, ct);
|
||||
return Results.Ok(new { tipId = id });
|
||||
})
|
||||
.WithName("UpsertAssistantTip")
|
||||
.WithSummary("Create or update a tip");
|
||||
|
||||
admin.MapDelete("/tips/{tipId}", async Task<IResult>(
|
||||
string tipId,
|
||||
PostgresAssistantStore store,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
await store.DeactivateTipAsync(tipId, ct);
|
||||
return Results.Ok();
|
||||
})
|
||||
.WithName("DeactivateAssistantTip")
|
||||
.WithSummary("Deactivate a tip");
|
||||
|
||||
admin.MapGet("/tips", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
[FromQuery] string? locale,
|
||||
[FromQuery] string? route,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var result = await store.ListAllTipsAsync(tenantId, locale ?? "en-US", route, ct);
|
||||
return Results.Ok(result);
|
||||
})
|
||||
.WithName("ListAllAssistantTips")
|
||||
.WithSummary("List all tips for admin editing");
|
||||
|
||||
admin.MapGet("/tours", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
[FromQuery] string? locale,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var result = await store.ListAllToursAsync(tenantId, locale ?? "en-US", ct);
|
||||
return Results.Ok(result);
|
||||
})
|
||||
.WithName("ListAllAssistantTours")
|
||||
.WithSummary("List all tours for admin editing");
|
||||
|
||||
admin.MapPost("/tours", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
UpsertTourRequest request,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var id = await store.UpsertTourAsync(tenantId, request, ct);
|
||||
return Results.Ok(new { tourId = id });
|
||||
})
|
||||
.WithName("UpsertAssistantTour")
|
||||
.WithSummary("Create or update a guided tour");
|
||||
|
||||
admin.MapGet("/tours/{tourKey}", async Task<IResult>(
|
||||
string tourKey,
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
[FromQuery] string? locale,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var tour = await store.GetTourByKeyAsync(tenantId, tourKey, locale ?? "en-US", ct);
|
||||
return tour is not null ? Results.Ok(tour) : Results.NotFound();
|
||||
})
|
||||
.WithName("GetAssistantTourByKey")
|
||||
.WithSummary("Get a single tour by key for editing");
|
||||
|
||||
admin.MapPost("/glossary", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PostgresAssistantStore store,
|
||||
UpsertGlossaryTermRequest request,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var id = await store.UpsertGlossaryTermAsync(tenantId, request, ct);
|
||||
return Results.Ok(new { termId = id });
|
||||
})
|
||||
.WithName("UpsertGlossaryTerm")
|
||||
.WithSummary("Create or update a glossary term");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static string ResolveUserId(HttpContext ctx)
|
||||
=> ctx.User.FindFirst("sub")?.Value
|
||||
?? ctx.User.FindFirst("stellaops:user_id")?.Value
|
||||
?? "anonymous";
|
||||
|
||||
private static string ResolveTenantId(HttpContext ctx)
|
||||
=> ctx.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";
|
||||
|
||||
private static (string UserId, string TenantId) ResolveUserContext(HttpContext ctx)
|
||||
{
|
||||
var userId = ctx.User.FindFirst("sub")?.Value
|
||||
?? ctx.User.FindFirst("stellaops:user_id")?.Value
|
||||
?? "anonymous";
|
||||
var tenantId = ResolveTenantId(ctx);
|
||||
return (userId, tenantId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user