feat(admin): summary progress and manual generation

This commit is contained in:
Dave 2026-09-16 16:16:56 +02:00
parent 6890a6d29e
commit cff4f9c7fd
3 changed files with 158 additions and 1 deletions

View file

@ -0,0 +1,62 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireSession } from "@/lib/auth/session";
import {
resetSummaryFailures,
summariseProject,
summaryStatus,
} from "@/lib/ai/summarise";
/** See the security note at the top of src/app/actions/cards.ts. */
async function requireOwner() {
const session = await requireSession();
if (session.role !== "owner") throw new Error("Not found");
return session;
}
export async function summariseProjectAction(input: {
projectId: string;
}): Promise<{ summarised: number; failed: number; message: string }> {
await requireOwner();
const result = await summariseProject(input.projectId);
revalidatePath(`/admin/projects/${input.projectId}`);
if (result.skipped) {
return {
summarised: 0,
failed: 0,
message: result.error ?? "No AI provider is configured.",
};
}
if (result.considered === 0) {
return { summarised: 0, failed: 0, message: "Everything is already summarised." };
}
const parts = [`${result.summarised} summarised`];
if (result.failed > 0) parts.push(`${result.failed} not returned`);
if (result.error) parts.push(result.error);
return {
summarised: result.summarised,
failed: result.failed,
message: parts.join(" · "),
};
}
export async function retryFailedSummariesAction(input: {
projectId: string;
}): Promise<{ reset: number }> {
await requireOwner();
const reset = await resetSummaryFailures(input.projectId);
revalidatePath(`/admin/projects/${input.projectId}`);
return { reset };
}
export async function summaryStatusAction(input: { projectId: string }) {
await requireOwner();
return summaryStatus(input.projectId);
}

View file

@ -3,7 +3,9 @@ import { notFound } from "next/navigation";
import { AppShell } from "@/components/shell/app-shell";
import { TopBar } from "@/components/shell/top-bar";
import { IngestPanel } from "@/components/admin/ingest-panel";
import { SummaryPanel } from "@/components/admin/summary-panel";
import { getAdminPb } from "@/lib/pb/admin";
import { summaryStatus } from "@/lib/ai/summarise";
import type { ProjectRecord } from "@/lib/pb/types";
/**
@ -35,12 +37,14 @@ export default async function ProjectAdminPage({
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
const summary = await summaryStatus(project.id);
return (
<AppShell role="owner" currentPath="/admin/projects">
<TopBar title={project.name} subtitle="Project settings" />
<div className="flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-2xl">
<div className="mx-auto max-w-2xl space-y-4">
<IngestPanel
projectId={project.id}
repoOwner={project.repo_owner}
@ -50,6 +54,7 @@ export default async function ProjectAdminPage({
lastIngestAt={project.last_ingest_at}
commitCount={commits.totalItems}
/>
<SummaryPanel projectId={project.id} status={summary} />
</div>
</div>
</AppShell>

View file

@ -0,0 +1,90 @@
"use client";
import { useState, useTransition } from "react";
import { Sparkles, RotateCcw } from "lucide-react";
import {
retryFailedSummariesAction,
summariseProjectAction,
} from "@/app/actions/summarise";
export function SummaryPanel({
projectId,
status,
}: {
projectId: string;
status: { total: number; summarised: number; stale: number; failing: number };
}) {
const [pending, startTransition] = useTransition();
const [message, setMessage] = useState<string | null>(null);
const remaining = status.total - status.summarised;
return (
<section className="space-y-4 rounded-[var(--radius-tile)] border border-border-subtle p-4">
<div>
<h2 className="text-sm font-semibold text-text-primary">
Plain-English summaries
</h2>
<p className="mt-0.5 text-xs text-text-muted">
<span className="tabular">{status.summarised}</span> of{" "}
<span className="tabular">{status.total}</span> commits summarised
{status.stale > 0 && (
<>
{" · "}
<span className="tabular">{status.stale}</span> written under an
older prompt
</>
)}
{/* Silent degradation to raw subjects, with no indication, would go
unnoticed for weeks. */}
{status.failing > 0 && (
<>
{" · "}
<span className="text-accent">
<span className="tabular">{status.failing}</span> failing
</span>
</>
)}
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
disabled={pending || remaining === 0}
onClick={() =>
startTransition(async () => {
setMessage("Generating…");
const result = await summariseProjectAction({ projectId });
setMessage(result.message);
})
}
className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg hover:bg-brand-hover disabled:opacity-50"
>
<Sparkles size={14} strokeWidth={2} />
{remaining === 0 ? "All summarised" : `Summarise ${remaining}`}
</button>
{status.failing > 0 && (
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await retryFailedSummariesAction({ projectId });
setMessage(`${result.reset} commits queued for another attempt.`);
})
}
className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-2.5 py-1.5 text-xs text-text-secondary hover:bg-surface-raised disabled:opacity-50"
>
<RotateCcw size={13} strokeWidth={2} />
Retry failing
</button>
)}
{message && <span className="text-xs text-text-muted">{message}</span>}
</div>
</section>
);
}