feat(admin): summary progress and manual generation
This commit is contained in:
parent
6890a6d29e
commit
cff4f9c7fd
3 changed files with 158 additions and 1 deletions
62
src/app/actions/summarise.ts
Normal file
62
src/app/actions/summarise.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
90
src/components/admin/summary-panel.tsx
Normal file
90
src/components/admin/summary-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue