feat(webui): add intelligence workspace

This commit is contained in:
joshpatra committed 2026-07-27 05:23:18 -04:00
1 parent 42a502639b
commit 6a85ddd1c5
6 files changed
+381 -11

No files matched your search

@@ -188,6 +188,8 @@ public sealed class IntelligenceControllerTests : IAsyncLifetime
SignalsJson = JsonSerializer.Serialize(new[] { new RecommendationSignal("similar", .9, "Shared listening context") }),
IdentityJson = JsonSerializer.Serialize(new RecommendationTrackIdentity(
MusicBrainzRecordingId: "11111111-1111-1111-1111-111111111111",
Title: "Recommended song",
Artist: "Recommended artist",
LibraryTrackId: Guid.Parse("22222222-2222-2222-2222-222222222222"),
BackendItemId: "backend-track-42")),
CreatedAt = DateTimeOffset.UtcNow
@@ -224,6 +226,8 @@ public sealed class IntelligenceControllerTests : IAsyncLifetime
var result = Assert.IsType<OkObjectResult>(await Controller().Get(Scope(), default)); var json = JsonSerializer.Serialize(result.Value);
Assert.Contains("Shared listening context", json); Assert.Contains("Private preview", json); Assert.Contains("visualization", json);
Assert.Contains("lastfm:fixture", json, StringComparison.Ordinal);
Assert.Contains("Recommended song", json, StringComparison.Ordinal);
Assert.Contains("\"latestRunState\":\"succeeded\"", json, StringComparison.Ordinal);
Assert.DoesNotContain("TenantId", json, StringComparison.Ordinal);
var feedback = Assert.IsType<OkObjectResult>(await Controller().SetFeedback(
Guid.Parse("33333333-3333-3333-3333-333333333333"), new()
+36 -11
View File
@@ -48,6 +48,16 @@ public sealed class IntelligenceController(
.Where(item => candidateIds.Contains(item.CandidateId) && item.TenantId == scope.TenantId &&
item.OwnerUserId == scope.OwnerUserId)
.ToDictionaryAsync(item => item.CandidateId, cancellationToken);
var candidateIdentities = candidates.ToDictionary(item => item.Id, item => ParseIdentity(item.IdentityJson));
var libraryTrackIds = candidateIdentities.Values.Select(item => item?.LibraryTrackId).OfType<Guid>().ToArray();
var canonicalIds = candidates.Select(item => item.CanonicalRecordingId).OfType<Guid>().ToArray();
var localTracks = await db.LibraryTracks.AsNoTracking().Where(item =>
item.TenantId == scope.TenantId && item.OwnerUserId == scope.OwnerUserId &&
item.Protocol == scope.Protocol && item.BackendInstanceId == scope.BackendInstanceId &&
item.LibraryScopeId == scope.LibraryScopeId &&
(libraryTrackIds.Contains(item.Id) ||
item.CanonicalRecordingId.HasValue && canonicalIds.Contains(item.CanonicalRecordingId.Value)))
.ToListAsync(cancellationToken);
var sets = await db.GeneratedSets.AsNoTracking().Where(item => item.TenantId == scope.TenantId &&
item.OwnerUserId == scope.OwnerUserId && item.Protocol == scope.Protocol &&
item.BackendInstanceId == scope.BackendInstanceId && item.LibraryScopeId == scope.LibraryScopeId)
@@ -105,21 +115,36 @@ public sealed class IntelligenceController(
{
canRun = policy?.Enabled == true && enabledProviders.Any(id => readinessById.TryGetValue(id, out var item) && item.State == RecommendationProviderReadinessState.Ready),
canGenerate = latestRun?.State == RecommendationRunState.Succeeded,
latestRunId = latestRun?.Id
latestRunId = latestRun?.Id,
latestRunState = latestRun?.State.ToString().ToLowerInvariant()
},
candidates = candidates.Select(item => new
candidates = candidates.Select(item =>
{
item.TrackKey,
item.Score,
item.Source,
item.CanonicalRecordingId,
item.ProviderAccountId,
item.SourceRevision,
explanations = ParseSignals(item.SignalsJson),
exclusions = ParseArray(item.ExclusionsJson),
feedback = feedback.TryGetValue(item.Id, out var value)
var identity = candidateIdentities[item.Id];
var local = localTracks.FirstOrDefault(track => track.Id == identity?.LibraryTrackId) ??
localTracks.FirstOrDefault(track => track.CanonicalRecordingId == item.CanonicalRecordingId);
return new
{
id = item.Id,
item.TrackKey,
title = identity?.Title ?? local?.Title,
artist = identity?.Artist ?? local?.Artist,
album = identity?.Album ?? local?.Album,
artworkUrl = local?.CoverArtReference == null ? null :
$"/api/admin/downloads/artwork/{Uri.EscapeDataString(local.BackendItemId)}",
item.Score,
item.Source,
providerId = identity?.ProviderId ?? item.Source,
item.CanonicalRecordingId,
item.ProviderAccountId,
item.SourceRevision,
item.Revision,
explanations = ParseSignals(item.SignalsJson),
exclusions = ParseArray(item.ExclusionsJson),
feedback = feedback.TryGetValue(item.Id, out var value)
? new { value.Kind, value.ReasonCode, value.UpdatedAt, value.Revision }
: null
};
}),
generatedSets = sets.Select(item => new
{
+84
View File
@@ -534,6 +534,65 @@ export type DownloadsResponse = {
count: number;
};
export type IntelligenceScope = {
protocol: string;
backendInstanceId: string;
libraryScopeId: string;
};
export type IntelligenceState = {
state: string;
message?: string | null;
scope: IntelligenceScope;
policy?: {
enabled: boolean;
retentionDays: number;
revision: number;
} | null;
availableSignalTypes: Array<{ id: string; label: string; enabled: boolean }>;
providers: Array<{
id: string;
label: string;
description: string;
enabled: boolean;
available: boolean;
state: string;
reasonCode?: string | null;
}>;
actions: {
canRun: boolean;
canGenerate: boolean;
latestRunId?: string | null;
latestRunState?: string | null;
};
candidates: Array<{
id: string;
trackKey: string;
title?: string | null;
artist?: string | null;
album?: string | null;
artworkUrl?: string | null;
score: number;
source: string;
providerId: string;
sourceRevision: string;
revision: number;
explanations: Array<{ code: string; weight: number; explanation: string }>;
exclusions: string[];
feedback?: { kind: string; reasonCode?: string | null; revision: number } | null;
}>;
generatedSets: Array<{
id: string;
name: string;
trackCount: number;
state: string;
materialized: boolean;
backendPlaylistId?: string | null;
errorCode?: string | null;
}>;
visualization: Array<{ key: string; label: string; value: number }>;
};
async function json<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
const response = await fetch(input, {
cache: "no-store",
@@ -567,6 +626,31 @@ export const onboarding = {
reopen: () => json<OnboardingState>("/api/admin/onboarding/reopen", { method: "POST" }),
};
const intelligenceBody = (value: object, method = "POST") => ({
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(value),
});
export const intelligence = {
get: (scope: IntelligenceScope) =>
json<IntelligenceState>(`/api/admin/intelligence?${new URLSearchParams(scope)}`),
savePolicy: (scope: IntelligenceScope, input: object) =>
json<{ revision: number }>("/api/admin/intelligence/policy",
intelligenceBody({ ...scope, ...input }, "PUT")),
run: (scope: IntelligenceScope, seedTrackKeys: string[] = []) =>
json<{ runId: string; jobId: string }>("/api/admin/intelligence/runs",
intelligenceBody({ ...scope, seedTrackKeys, limit: 25, idempotencyKey: crypto.randomUUID() })),
generate: (scope: IntelligenceScope, runId: string, name: string) =>
json<{ id: string }>("/api/admin/intelligence/generated-sets",
intelligenceBody({ ...scope, runId, name })),
feedback: (scope: IntelligenceScope, candidateId: string, kind: string, expectedRevision: number) =>
json<{ revision: number }>(`/api/admin/intelligence/candidates/${encodeURIComponent(candidateId)}/feedback`,
intelligenceBody({ ...scope, kind, expectedRevision }, "PUT")),
purge: (scope: IntelligenceScope) =>
json<void>("/api/admin/intelligence/data", intelligenceBody(scope, "DELETE")),
};
export const home = {
schema: () =>
json<UiSchema>("/api/admin/ui/schema"),
@@ -0,0 +1,196 @@
<script lang="ts">
import { AlertDialog } from "bits-ui";
import { intelligence, type IntelligenceScope, type IntelligenceState } from "$lib/api";
let protocol = $state("jellyfin");
let backendInstanceId = $state("");
let libraryScopeId = $state("");
let data = $state<IntelligenceState | null>(null);
let loading = $state(false);
let action = $state("");
let error = $state("");
let purgeOpen = $state(false);
let generatedName = $state("Your recommendations");
let enabled = $state(false);
let retentionDays = $state(30);
let selectedSignals = $state<string[]>([]);
let selectedProviders = $state<string[]>([]);
const scope = $derived<IntelligenceScope>({ protocol, backendInstanceId, libraryScopeId });
const visibleCandidates = $derived(data?.candidates.filter((item) => !item.exclusions.length) ?? []);
function adopt(next: IntelligenceState) {
data = next;
enabled = Boolean(next.policy?.enabled);
retentionDays = next.policy?.retentionDays ?? 30;
selectedSignals = next.availableSignalTypes.filter((item) => item.enabled).map((item) => item.id);
selectedProviders = next.providers.filter((item) => item.enabled).map((item) => item.id);
}
async function load() {
if (!backendInstanceId.trim() || !libraryScopeId.trim()) return;
loading = true;
error = "";
try {
adopt(await intelligence.get(scope));
} catch (cause) {
data = null;
error = cause instanceof Error ? cause.message : "Intelligence could not be loaded.";
} finally {
loading = false;
}
}
async function perform(name: string, operation: () => Promise<unknown>) {
if (action) return;
action = name;
error = "";
try {
await operation();
adopt(await intelligence.get(scope));
} catch (cause) {
error = cause instanceof Error ? cause.message : "The action could not be completed.";
} finally {
action = "";
}
}
function toggle(values: string[], value: string, checked: boolean) {
return checked ? [...new Set([...values, value])] : values.filter((item) => item !== value);
}
</script>
<section class="intelligence-view">
<header class="route-heading">
<div>
<p class="eyebrow">Private discovery</p>
<h2>Listen deeper, without exposing your history.</h2>
<p>Explainable recommendations and generated playlists stay scoped to one account and library.</p>
</div>
{#if data?.actions.canRun}
<div class="heading-actions">
{#if data.actions.latestRunState}<span class={`status-pill ${data.actions.latestRunState === "succeeded" ? "healthy" : "suggested"}`}>{data.actions.latestRunState}</span>{/if}
<button class="button-primary" type="button" disabled={Boolean(action)}
onclick={() => void perform("run", () => intelligence.run(scope))}>
{action === "run" ? "Starting…" : "Refresh recommendations"}
</button>
</div>
{/if}
</header>
<form class="panel scope-card" onsubmit={(event) => { event.preventDefault(); void load(); }}>
<label class="field"><span>Media server</span><select bind:value={protocol}><option value="jellyfin">Jellyfin</option><option value="subsonic">Subsonic</option></select></label>
<label class="field"><span>Backend instance</span><input bind:value={backendInstanceId} maxlength="200" placeholder="main" required /></label>
<label class="field"><span>Library scope</span><input bind:value={libraryScopeId} maxlength="300" placeholder="music" required /></label>
<button class="button-secondary" type="submit" disabled={loading}>{loading ? "Loading…" : "Open library"}</button>
</form>
{#if error}<p class="notice-error" role="alert">{error}</p>{/if}
{#if loading}
<div class="intelligence-grid" aria-busy="true" aria-label="Loading Intelligence">
{#each Array(3) as _}<div class="panel skeleton-panel"></div>{/each}
</div>
{:else if !data}
<section class="panel empty-state">
<span class="empty-orbit" aria-hidden="true">✦</span>
<p class="eyebrow">Choose a library</p>
<h2>Your recommendations begin with an exact scope.</h2>
<p>Enter the backend instance and library IDs used by your media server.</p>
</section>
{:else if data.state === "unauthorized" || data.state === "error"}
<section class="panel route-error" role="alert">
<span aria-hidden="true">!</span><div><p class="eyebrow">{data.state}</p><h2>This library is unavailable.</h2><p>{data.message}</p></div>
<button class="button-secondary" type="button" onclick={() => void load()}>Try again</button>
</section>
{:else}
{#if data.state === "degraded"}
<div class="degraded-banner" role="status"><span aria-hidden="true">!</span><p><strong>Some discovery sources need attention.</strong> Existing results remain available.</p></div>
{/if}
<div class="intelligence-grid">
<section class="panel recommendations">
<header><div><p class="eyebrow">For you</p><h3>Recommendations</h3></div><span>{visibleCandidates.length} tracks</span></header>
{#if visibleCandidates.length}
<ol class="recommendation-list">
{#each visibleCandidates as item}
<li>
<span class="track-art">
{#if item.artworkUrl}<img src={item.artworkUrl} alt="" loading="lazy" />{:else}<span aria-hidden="true">♪</span>{/if}
</span>
<div class="track-copy">
<strong>{item.title || item.trackKey}</strong>
<small>{item.artist || item.providerId}{item.album ? ` · ${item.album}` : ""}</small>
<details><summary>Why this track</summary><ul>{#each item.explanations as reason}<li>{reason.explanation}</li>{/each}</ul></details>
</div>
<div class="track-actions">
<span class="score">{Math.round(item.score * 100)}%</span>
<button type="button" disabled={Boolean(action)} onclick={() => void perform(`similar:${item.id}`, () => intelligence.run(scope, [item.trackKey]))}>Similar</button>
<button type="button" aria-label={`Dismiss ${item.title || item.trackKey}`} disabled={Boolean(action)}
onclick={() => void perform(`dismiss:${item.id}`, () => intelligence.feedback(scope, item.id, "dismiss", item.feedback?.revision ?? 0))}>×</button>
</div>
</li>
{/each}
</ol>
{:else}
<div class="compact-empty"><strong>No recommendations yet</strong><p>Enable at least one ready source, then run a refresh.</p></div>
{/if}
</section>
<aside class="side-stack">
<section class="panel profile-card">
<p class="eyebrow">Recent listening</p><h3>Your profile</h3>
{#if data.visualization.length}
{#each data.visualization as item}
<label><span>{item.label}</span><meter min="0" max="1" value={item.value}>{item.value}</meter></label>
{/each}
{:else}<p class="muted">No retained listening signals yet.</p>{/if}
</section>
<section class="panel generated-card">
<p class="eyebrow">Saved output</p><h3>Generated playlists</h3>
{#each data.generatedSets as item}
<div class="generated-row"><span><strong>{item.name}</strong><small>{item.trackCount} tracks</small></span><span class={`status-pill ${item.materialized ? "healthy" : "suggested"}`}>{item.state}</span></div>
{:else}<p class="muted">No generated playlists yet.</p>{/each}
{#if data.actions.canGenerate && data.actions.latestRunId}
<form class="generate-form" onsubmit={(event) => { event.preventDefault(); void perform("generate", () => intelligence.generate(scope, data!.actions.latestRunId!, generatedName)); }}>
<label class="field"><span>Playlist name</span><input bind:value={generatedName} maxlength="200" required /></label>
<button class="button-primary" type="submit" disabled={Boolean(action)}>{action === "generate" ? "Creating…" : "Create playlist"}</button>
</form>
{/if}
</section>
</aside>
<section class="panel privacy-card">
<header><div><p class="eyebrow">Control</p><h3>Privacy and sources</h3></div><span class={`status-pill ${enabled ? "healthy" : "suggested"}`}>{enabled ? "Enabled" : "Off"}</span></header>
<form onsubmit={(event) => {
event.preventDefault();
void perform("policy", () => intelligence.savePolicy(scope, {
enabled, retentionDays, allowedSignalTypes: selectedSignals,
enabledProviders: selectedProviders, expectedRevision: data!.policy?.revision ?? 0,
}));
}}>
<label class="toggle-line"><input type="checkbox" bind:checked={enabled} /><span><strong>Use my listening signals</strong><small>Nothing is retained until this is enabled.</small></span></label>
<label class="field"><span>Retention</span><select bind:value={retentionDays}><option value={7}>7 days</option><option value={30}>30 days</option><option value={90}>90 days</option><option value={365}>1 year</option></select></label>
<fieldset><legend>Signals</legend>{#each data.availableSignalTypes as item}<label><input type="checkbox" checked={selectedSignals.includes(item.id)} onchange={(event) => selectedSignals = toggle(selectedSignals, item.id, event.currentTarget.checked)} /> {item.label}</label>{/each}</fieldset>
<fieldset><legend>Sources</legend>{#each data.providers as provider}<label class:unavailable={!provider.available}><input type="checkbox" disabled={!provider.available} checked={selectedProviders.includes(provider.id)} onchange={(event) => selectedProviders = toggle(selectedProviders, provider.id, event.currentTarget.checked)} /> <span><strong>{provider.label}</strong><small>{provider.description} · {provider.state}</small></span></label>{/each}</fieldset>
<footer><button class="button-danger" type="button" onclick={() => purgeOpen = true}>Turn off and clear</button><button class="button-primary" type="submit" disabled={Boolean(action)}>{action === "policy" ? "Saving…" : "Save settings"}</button></footer>
</form>
</section>
</div>
{/if}
</section>
<AlertDialog.Root bind:open={purgeOpen}>
<AlertDialog.Portal><AlertDialog.Overlay class="dialog-overlay" /><AlertDialog.Content class="confirm-dialog">
<AlertDialog.Title>Clear this library’s Intelligence data?</AlertDialog.Title>
<AlertDialog.Description>Retained signals, profiles, recommendations, feedback, and generated sets for this exact scope will be removed.</AlertDialog.Description>
<footer><AlertDialog.Cancel class="button-secondary">Keep my data</AlertDialog.Cancel><AlertDialog.Action class="button-danger" onclick={() => void perform("purge", () => intelligence.purge(scope))}>Turn off and clear</AlertDialog.Action></footer>
</AlertDialog.Content></AlertDialog.Portal>
</AlertDialog.Root>
<style>
.intelligence-view{display:grid;gap:1.25rem}.route-heading{display:flex;align-items:end;justify-content:space-between;gap:1rem}.route-heading h2{margin:.25rem 0;font-family:var(--font-display);font-size:clamp(1.5rem,3vw,2.2rem)}.route-heading p:last-child,.muted{color:var(--color-ink-muted)}.heading-actions{display:flex;align-items:center;gap:.75rem}.scope-card{display:grid;grid-template-columns:repeat(3,minmax(0,1fr)) auto;align-items:end;gap:1rem;padding:1rem}.field select{min-height:3rem;border:1px solid var(--color-edge);border-radius:.8rem;background:var(--color-panel-raised);color:var(--color-ink);padding:0 .9rem}.intelligence-grid{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(18rem,.8fr);gap:1rem}.recommendations,.profile-card,.generated-card,.privacy-card{padding:1.15rem}.recommendations>header,.privacy-card>header{display:flex;align-items:center;justify-content:space-between}.recommendations h3,.profile-card h3,.generated-card h3,.privacy-card h3{margin:.2rem 0 1rem}.recommendation-list{display:grid;margin:0;padding:0;list-style:none}.recommendation-list>li{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.85rem;align-items:center;border-top:1px solid var(--color-edge);padding:.9rem 0}.track-art{display:grid;width:3rem;height:3rem;place-items:center;overflow:hidden;border-radius:.7rem;background:var(--color-panel-raised);color:var(--color-signal)}.track-art img{width:100%;height:100%;object-fit:cover}.track-copy{min-width:0}.track-copy>strong,.track-copy>small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.track-copy small,summary{color:var(--color-ink-muted);font-size:.75rem}.track-copy details{margin-top:.35rem}.track-copy ul{margin:.4rem 0 0;padding-left:1.1rem;color:var(--color-ink-muted);font-size:.78rem}.track-actions{display:flex;align-items:center;gap:.35rem}.track-actions button{border:1px solid var(--color-edge);border-radius:.55rem;background:transparent;color:var(--color-ink-muted);padding:.35rem .5rem;cursor:pointer}.score{color:var(--color-signal);font-size:.75rem;font-weight:800}.side-stack{display:grid;align-content:start;gap:1rem}.profile-card label,.generated-row{display:flex;align-items:center;justify-content:space-between;gap:1rem;border-top:1px solid var(--color-edge);padding:.7rem 0}.profile-card meter{width:55%;accent-color:var(--color-signal)}.generated-row span:first-child strong,.generated-row span:first-child small{display:block}.generated-row small{color:var(--color-ink-muted)}.generate-form{display:grid;gap:.75rem;margin-top:1rem}.privacy-card{grid-column:1/-1}.privacy-card form{display:grid;grid-template-columns:minmax(14rem,.6fr) 1fr 1fr;gap:1rem}.toggle-line{display:flex;gap:.75rem}.toggle-line span>*{display:block}.toggle-line small,fieldset small{color:var(--color-ink-muted)}fieldset{display:grid;align-content:start;gap:.55rem;border:0;margin:0;padding:0}fieldset legend{margin-bottom:.55rem;font-weight:750}fieldset label{display:flex;gap:.5rem}.unavailable{opacity:.5}.privacy-card footer{grid-column:1/-1;display:flex;justify-content:space-between;gap:.75rem;border-top:1px solid var(--color-edge);padding-top:1rem}
@media(max-width:900px){.scope-card{grid-template-columns:1fr 1fr}.intelligence-grid{grid-template-columns:1fr}.privacy-card{grid-column:auto}.privacy-card form{grid-template-columns:1fr 1fr}}
@media(max-width:620px){.route-heading{align-items:stretch;flex-direction:column}.scope-card,.privacy-card form{grid-template-columns:1fr}.recommendation-list>li{grid-template-columns:auto minmax(0,1fr)}.track-actions{grid-column:2;flex-wrap:wrap}.privacy-card footer{grid-column:auto;flex-direction:column-reverse}.privacy-card footer>*{width:100%}}
</style>
+2
View File
@@ -8,6 +8,7 @@
const destinations = [
{ href: "#/", label: "Home", icon: "⌂" },
{ href: "#/library/playlists", prefix: "/library/", label: "Library", icon: "♫" },
{ href: "#/intelligence", label: "Intelligence", icon: "✦" },
{ href: "#/sources", label: "Sources", icon: "◎" },
{ href: "#/activity", label: "Activity", icon: "↗" },
{ href: "#/settings", label: "Settings", icon: "⚙" },
@@ -107,6 +108,7 @@
return import("$lib/components/DownloadsView.svelte");
}
if (path === "/activity") return import("$lib/components/EventLogView.svelte");
if (path === "/intelligence") return import("$lib/components/IntelligenceView.svelte");
if (path === "/sources") return import("$lib/components/SourcesView.svelte");
if (path.startsWith("/settings")) return import("$lib/components/SettingsView.svelte");
}
+59
View File
@@ -163,6 +163,30 @@ const responses: Record<string, unknown> = {
"/api/admin/extensions/registries": [],
"/api/admin/extensions/store": { items: [], errors: [] },
"/api/admin/extensions/logs?limit=100": [],
"/api/admin/intelligence": {
state: "configured",
scope: { protocol: "jellyfin", backendInstanceId: "main", libraryScopeId: "music" },
policy: { enabled: true, retentionDays: 30, revision: 1 },
availableSignalTypes: [{ id: "play", label: "Play", enabled: true }],
providers: [{
id: "lumen-audio", label: "Lumen Audio", description: "Private similarity source.",
enabled: true, available: true, state: "ready",
}],
actions: {
canRun: true, canGenerate: true, latestRunId: "run-1", latestRunState: "succeeded",
},
candidates: [{
id: "candidate-1", trackKey: "track-1", title: "Future Song", artist: "Artist",
album: "Album", score: .91, source: "lumen-audio", providerId: "lumen-audio",
sourceRevision: "fixture:1", revision: 1, explanations: [{
code: "similar", weight: .9, explanation: "Similar to recent listening.",
}], exclusions: [], feedback: null,
}],
generatedSets: [{
id: "set-1", name: "Morning discovery", trackCount: 25, state: "succeeded", materialized: true,
}],
visualization: [{ key: "plays", label: "Plays", value: .7 }],
},
};
async function mockApi(page: Page, options: { delay?: string; fail?: string[] } = {}) {
@@ -261,6 +285,16 @@ async function mockApi(page: Page, options: { delay?: string; fail?: string[] }
body = { success: true, alreadyApplied: false };
if (url.pathname === "/api/admin/config/migration/reset")
body = {};
if (url.pathname === "/api/admin/intelligence/runs")
body = { runId: "run-2", jobId: "job-2" };
if (url.pathname === "/api/admin/intelligence/generated-sets")
body = { id: "set-2" };
if (url.pathname.includes("/api/admin/intelligence/candidates/") && url.pathname.endsWith("/feedback"))
body = { revision: 1 };
if (url.pathname === "/api/admin/intelligence/policy")
body = { revision: 2 };
if (url.pathname === "/api/admin/intelligence/data")
body = {};
await route.fulfill({
status: body === undefined ? 404 : 200,
contentType: "application/json",
@@ -276,6 +310,7 @@ const routes = [
["#/library/cached", "Library"],
["#/library/kept", "Library"],
["#/activity", "Activity"],
["#/intelligence", "Intelligence"],
["#/sources", "Sources"],
["#/settings/general", "Settings"],
] as const;
@@ -339,6 +374,30 @@ for (const viewport of viewports) {
await expect(dialog.getByRole("button", { name: "Purge cache" })).toBeInViewport();
});
test("Intelligence results remain usable", async ({ page, context }) => {
await mockApi(page, { delay: "/api/admin/intelligence" });
await page.goto("#/intelligence");
await page.getByLabel("Backend instance").fill("main");
await page.getByLabel("Library scope").fill("music");
await page.getByRole("button", { name: "Open library" }).click();
await expect(page.getByLabel("Loading Intelligence")).toBeVisible();
await expect(page.getByText("Future Song")).toBeVisible();
await expect(page.getByText("Morning discovery")).toBeVisible();
await expect(page.getByText("Private similarity source. · ready")).toBeVisible();
await expect(page.getByRole("button", { name: "Refresh recommendations" })).toBeInViewport();
await expect.poll(() => page.evaluate(() =>
document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
const errorPage = await context.newPage();
await mockApi(errorPage, { fail: ["/api/admin/intelligence"] });
await errorPage.goto("#/intelligence");
await errorPage.getByLabel("Backend instance").fill("main");
await errorPage.getByLabel("Library scope").fill("music");
await errorPage.getByRole("button", { name: "Open library" }).click();
await expect(errorPage.getByRole("alert")).toContainText("Fixture unavailable");
await expect(errorPage.getByRole("button", { name: "Open library" })).toBeInViewport();
});
test("Source dialogs remain usable", async ({ page }) => {
await mockApi(page);
await page.goto("#/sources");