diff --git a/src/app/actions/summarise.ts b/src/app/actions/summarise.ts new file mode 100644 index 0000000..16e6f76 --- /dev/null +++ b/src/app/actions/summarise.ts @@ -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); +} diff --git a/src/app/admin/projects/[projectId]/page.tsx b/src/app/admin/projects/[projectId]/page.tsx index 0065de6..af3aee5 100644 --- a/src/app/admin/projects/[projectId]/page.tsx +++ b/src/app/admin/projects/[projectId]/page.tsx @@ -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 (
-
+
+
diff --git a/src/components/admin/summary-panel.tsx b/src/components/admin/summary-panel.tsx new file mode 100644 index 0000000..d1a161d --- /dev/null +++ b/src/components/admin/summary-panel.tsx @@ -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(null); + + const remaining = status.total - status.summarised; + + return ( +
+
+

+ Plain-English summaries +

+

+ {status.summarised} of{" "} + {status.total} commits summarised + {status.stale > 0 && ( + <> + {" · "} + {status.stale} written under an + older prompt + + )} + {/* Silent degradation to raw subjects, with no indication, would go + unnoticed for weeks. */} + {status.failing > 0 && ( + <> + {" · "} + + {status.failing} failing + + + )} +

+
+ +
+ + + {status.failing > 0 && ( + + )} + + {message && {message}} +
+
+ ); +}