feat(reports): show chapter progress while a report is being written (BUG-601)

The worker already persists a phase ladder and durable per-section rows;
the waiting screen parsed the progress fields and rendered none of them.
Chapter progress now drives the screen: one cell per chapter rather than
a percentage bar, since the job percent jumps 0->30 and 90->100.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016P5RoqzmUQEbeC2qjAkeGr
This commit is contained in:
Jesse_Chen
2026-09-09 03:30:15 +00:00
co-authored by Claude Fable 5
parent 301eae4b76
commit 848e39e61f
9 changed files with 684 additions and 18 deletions
@@ -57,9 +57,16 @@ async function resolvePersistenceForUser() {
userId: user.id,
persistence,
jobs: createSupabasePersonalReportJobService(supabase),
listSections: async (ownerId: string, requestId: string) => {
// sectionId and attemptCount are consumed by resolveReportRead to derive
// reader-visible chapter state; neither is forwarded to the browser raw.
listSections: async (ownerId: string, requestId: string) => {
const rows = await sections.list(ownerId, requestId);
return rows.map((row) => ({ status: row.status, lastErrorCode: row.lastErrorCode }));
return rows.map((row) => ({
sectionId: row.sectionId,
status: row.status,
attemptCount: row.attemptCount,
lastErrorCode: row.lastErrorCode,
}));
},
loadLongformAppendix: async (input: Readonly<{ userId: string; reportId: string }>) => {
const appendixRead = await supabase
+59
View File
@@ -3775,6 +3775,65 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
text-wrap: pretty;
}
/* Report generation progress: one cell per chapter, never a percentage.
See frontend/DESIGN.md · 报告生成等待态. */
.personal-report-state p.personal-report-progress-headline {
color: var(--color-ink);
font-family: var(--font-display);
font-size: var(--type-display-sm);
font-weight: 400;
letter-spacing: -.3px;
font-variant-numeric: tabular-nums;
}
.report-progress {
display: flex;
flex-direction: column;
gap: var(--space-4);
width: min(20rem, 100%);
}
.report-progress-track {
display: flex;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.report-progress-track li {
flex: 1 1 0;
height: 4px;
border-radius: 2px;
background: var(--color-border);
}
.report-progress-track li[data-state="done"] { background: var(--color-ink); }
.report-progress-track li[data-state="failed"] { background: var(--color-ink-tertiary); }
.report-progress-track li[data-state="writing"] { background: var(--color-ink-secondary); }
.report-progress-chapters {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin: 0;
padding: 0;
list-style: none;
font-size: var(--type-body-sm);
line-height: 1.5;
}
.report-progress-chapters li {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4);
color: var(--color-ink-secondary);
}
.report-progress-chapters li[data-state="done"] { color: var(--color-ink); }
.report-progress-chapters li[data-state="writing"] { color: var(--color-ink); }
.report-progress-chapter-name { text-align: left; }
.report-progress-chapter-status {
color: var(--color-ink-tertiary);
text-align: right;
white-space: nowrap;
}
.report-progress-chapters li[data-state="waiting"] { color: var(--color-ink-tertiary); }
.personal-report-document {
width: min(900px, 100%);
margin: var(--space-8) auto 0;
@@ -25,6 +25,7 @@ import {
type ReportDocument,
type ReportDocumentV2,
} from "@/lib/personal-report-contract";
import { reportThemeLabel } from "@/lib/personal-report-progress";
const CLAIM_STATUS_LABELS: Record<ClaimStatus, string> = {
multi_system_consensus: "多系统一致",
@@ -61,15 +62,6 @@ const DEPTH_LABELS: Record<ReportDocumentV2["depth"], string> = {
research: "研究",
};
const THEME_LABELS: Record<string, string> = {
general: "综合",
career: "事业",
wealth: "财富",
marriage: "婚恋",
health: "健康",
education: "教育",
timing: "应期",
};
const ACTION_PRIORITY_LABELS: Record<ReportDocumentV2["actionNotes"][number]["priority"], string> = {
now: "现在",
@@ -152,7 +144,7 @@ function planetPlacement(chart: ChartV1 | undefined, aliases: readonly string[])
}
function themeLabel(theme: string): string {
return THEME_LABELS[theme] ?? theme;
return reportThemeLabel(theme);
}
function chartEvidenceRefs(chart: ReportChart): readonly string[] {
@@ -19,10 +19,18 @@ import { InlineSpinner } from "@/components/inline-spinner";
import { ReportActions } from "./report-actions";
import { PersonalReportMarkdownView } from "./personal-report-markdown-view";
import { PersonalReportProgressPanel } from "./personal-report-progress-panel";
import {
PERSONAL_REPORT_GENERATING_COPY,
PERSONAL_REPORT_LEGACY_PLACEHOLDER,
} from "@/lib/personal-report-longform-copy";
import {
describeReportProgress,
REPORT_PROGRESS_STALL_MS,
reportProgressSignature,
type ReportSectionProgress,
type ReportSectionProgressState,
} from "@/lib/personal-report-progress";
import { Button } from "@/components/ui/button";
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
@@ -30,7 +38,12 @@ export type ReportLoadState =
| { phase: "loading" }
| { phase: "unauthorized" }
| { phase: "not-found" }
| { phase: "generating"; progressPercent?: number; progressPhase?: string }
| {
phase: "generating";
progressPercent?: number;
progressPhase?: string;
sections?: readonly ReportSectionProgress[];
}
| { phase: "timed-out" }
| { phase: "failed"; failureCode: string | null; failureSummary?: string | null; appendixLastErrorCode?: string | null }
| { phase: "invalid"; message: string }
@@ -52,6 +65,31 @@ export interface ReportEnvelopeView {
completedAt: string | null;
progressPercent?: number;
progressPhase?: string;
sections?: readonly ReportSectionProgress[];
}
const SECTION_PROGRESS_STATES: readonly ReportSectionProgressState[] = [
"done",
"failed",
"writing",
"waiting",
];
/**
* Chapter rows are only trusted in the shape the route promises; anything else
* is dropped rather than rendered as a mystery row.
*/
function readSectionProgress(value: unknown): readonly ReportSectionProgress[] | undefined {
if (!Array.isArray(value)) return undefined;
const parsed = value.flatMap((entry) => {
if (!isRecord(entry)) return [];
const { id, state } = entry;
if (typeof id !== "string" || id.length === 0) return [];
if (typeof state !== "string") return [];
const known = SECTION_PROGRESS_STATES.find((candidate) => candidate === state);
return known ? [{ id, state: known }] : [];
});
return parsed.length > 0 ? parsed : undefined;
}
/**
@@ -96,10 +134,12 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
case "generating": {
const progressPercent = typeof view.progressPercent === "number" ? view.progressPercent : undefined;
const progressPhase = typeof view.progressPhase === "string" ? view.progressPhase : undefined;
const sections = readSectionProgress(view.sections);
return {
phase: "generating",
...(progressPercent === undefined ? {} : { progressPercent }),
...(progressPhase === undefined ? {} : { progressPhase }),
...(sections === undefined ? {} : { sections }),
};
}
case "failed": {
@@ -152,6 +192,10 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
const [state, setState] = useState<ReportLoadState>({ phase: "loading" });
const [waitStartedAt, setWaitStartedAt] = useState<number | null>(null);
const [waitedMs, setWaitedMs] = useState(0);
// When observable progress last changed. Only a real change to the phase,
// percent or chapter states moves this, so a chapter being retried keeps
// the old mark and eventually trips the slow notice.
const [progressMark, setProgressMark] = useState<{ signature: string; at: number } | null>(null);
const cancelledRef = useRef(false);
const load = useCallback(() => {
@@ -170,9 +214,18 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
const next = classifyReportEnvelope(response.status, json);
if (next.phase === "generating") {
setWaitStartedAt((startedAt) => startedAt ?? Date.now());
const signature = reportProgressSignature({
phase: next.progressPhase,
percent: next.progressPercent,
sections: next.sections,
});
setProgressMark((mark) => (
mark && mark.signature === signature ? mark : { signature, at: Date.now() }
));
} else {
setWaitStartedAt(null);
setWaitedMs(0);
setProgressMark(null);
}
setState(next);
})
@@ -191,6 +244,7 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
const keepWaiting = useCallback(() => {
setWaitStartedAt(Date.now());
setWaitedMs(0);
setProgressMark(null);
setState({ phase: "generating" });
void load();
}, [load]);
@@ -229,12 +283,27 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
});
if (state.phase === "loading" || state.phase === "generating") {
// The stall clock rides the existing one-second tick: waitStartedAt +
// waitedMs is "now" without a second timer or an effect.
const stalled = state.phase === "generating"
&& waitStartedAt !== null
&& progressMark !== null
&& (waitStartedAt + waitedMs) - progressMark.at >= REPORT_PROGRESS_STALL_MS;
const progress = state.phase === "generating"
? describeReportProgress({
phase: state.progressPhase,
sections: state.sections,
stalled,
})
: null;
const writing = progress?.stage === "writing";
return (
<main className="personal-report-state">
<InlineSpinner className="text-primary" size={32} />
<p role="status">
{generating ? PERSONAL_REPORT_GENERATING_COPY : "正在加载报告…"}
{writing ? null : <InlineSpinner className="text-primary" size={32} />}
<p role="status" className={writing ? "personal-report-progress-headline" : undefined}>
{generating ? (progress?.headline ?? PERSONAL_REPORT_GENERATING_COPY) : "正在加载报告…"}
</p>
{progress && writing && <PersonalReportProgressPanel progress={progress} />}
{generating && (
<>
<p>已等待 {formatWaitedDuration(waitedMs)}</p>
@@ -0,0 +1,35 @@
/**
* Chapter progress for a report that is still being written.
*
* One cell per chapter — the bar is the chapter list in compact form, so the
* two can never disagree. Deliberately not a percentage bar: the job's percent
* jumps 0→30 and 90→100 in single steps, and only the middle band tracks real
* work, so a linear percentage would read as motion the backend has not made.
*
* Nothing animates on a timer. A chapter under retry looks stalled here
* because it is stalled.
*/
"use client";
import type { ReportProgressView } from "@/lib/personal-report-progress";
export function PersonalReportProgressPanel({ progress }: { progress: ReportProgressView }) {
return (
<div className="report-progress">
<ol className="report-progress-track" aria-hidden="true">
{progress.chapters.map((chapter) => (
<li key={chapter.id} data-state={chapter.state} />
))}
</ol>
<ul className="report-progress-chapters">
{progress.chapters.map((chapter) => (
<li key={chapter.id} data-state={chapter.state}>
<span className="report-progress-chapter-name">{chapter.label}</span>
<span className="report-progress-chapter-status">{chapter.statusText}</span>
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,183 @@
/**
* Personal-report generation progress: the shared vocabulary between the API
* route (which reads durable section rows) and the waiting screen.
*
* The waiting screen exists because a full report takes minutes. Everything
* here is derived from state the worker already persists — nothing is
* interpolated, smoothed, or extrapolated. If the backend has not moved, the
* screen does not move either.
*
* Privacy boundary, drawn against BUG-043 (a rectification surface that
* re-rendered backend evidence state as a panel the user had to manage):
* chapter names and per-chapter state are the reader's own deliverable
* structure on a read-only waiting screen, so they may be shown. Attempt
* counts, raw error codes, lease tokens, job ids and payloads are backend
* state and must never cross into the response.
* `deriveSectionProgressState` is the one place attemptCount is read, and it
* runs on the server.
*/
/** Chapter titles shown to the reader; shared with the report document view. */
export const REPORT_THEME_LABELS: Readonly<Record<string, string>> = {
general: "综合",
career: "事业",
wealth: "财富",
marriage: "婚恋",
health: "健康",
education: "教育",
timing: "应期",
};
export function reportThemeLabel(theme: string): string {
return REPORT_THEME_LABELS[theme] ?? theme;
}
/**
* Per-chapter state as the reader sees it.
* - `done` section row is `ready`
* - `failed` section row is `blocked` (attempts exhausted); still counts as
* finished work, so the bar advances rather than stalling
* - `writing` row is `pending` but has been claimed at least once
* - `waiting` row is `pending` and untouched
*/
export type ReportSectionProgressState = "done" | "failed" | "writing" | "waiting";
export type ReportSectionProgress = Readonly<{
id: string;
state: ReportSectionProgressState;
}>;
/**
* Server-side mapping from a durable section row to reader-visible state.
*
* `start_personal_report_section` bumps `attempt_count` and leaves `status`
* at `pending`, so a claimed-but-unfinished chapter is exactly
* `pending && attemptCount > 0`. That makes "which chapter is being written"
* an observed fact rather than an inference from the completed count — the
* section list arrives ordered by section_id, not by write order, so counting
* forward from the finished chapters would name the wrong one.
*/
export function deriveSectionProgressState(
status: string,
attemptCount: number,
): ReportSectionProgressState {
if (status === "ready") return "done";
if (status === "blocked") return "failed";
return attemptCount > 0 ? "writing" : "waiting";
}
/**
* Stage of the generation run, read from the job's progress phase.
* The worker ladder is queued(0) → loading_context(10) → generating_report(30)
* → section:<id>(30..85, one step per finished chapter) → persisting_report(90)
* → ready(100).
*/
export type ReportProgressStage = "preparing" | "writing" | "finishing";
export function classifyReportProgressStage(phase: string | undefined): ReportProgressStage {
if (phase === "persisting_report") return "finishing";
if (phase !== undefined && phase.startsWith("section:")) return "writing";
return "preparing";
}
/**
* How long one unchanged progress signature may last before the screen says
* so. A chapter that fails is retried (max_attempts 2) with no change to the
* job's phase or percent, so this is the only signal the reader gets that a
* chapter is taking two passes. We say it is slow; we do not claim to know
* which attempt it is, because attemptCount never reaches the client.
*/
export const REPORT_PROGRESS_STALL_MS = 90_000;
export type ReportProgressChapter = Readonly<{
id: string;
label: string;
state: ReportSectionProgressState;
statusText: string;
}>;
export type ReportProgressView = Readonly<{
stage: ReportProgressStage;
/** Sentence for the status line; always present. */
headline: string;
/** Empty until the run reaches the writing stage. */
chapters: readonly ReportProgressChapter[];
finished: number;
total: number;
/** True when at least one chapter is blocked; the run can still finish. */
hasFailure: boolean;
}>;
const STATE_TEXT: Readonly<Record<ReportSectionProgressState, string>> = {
done: "已完成",
failed: "写作失败",
writing: "正在写",
waiting: "待写",
};
/**
* Build the reader-facing view. `stalled` is passed in rather than computed
* from a clock so this stays a pure function the tests can drive.
*/
export function describeReportProgress(input: Readonly<{
phase?: string;
sections?: readonly ReportSectionProgress[];
stalled?: boolean;
}>): ReportProgressView {
const stage = classifyReportProgressStage(input.phase);
const sections = input.sections ?? [];
const chapters = sections.map((section) => ({
id: section.id,
label: reportThemeLabel(section.id),
state: section.state,
statusText: section.state === "writing" && input.stalled
? "用时较长,仍在写"
: STATE_TEXT[section.state],
}));
const finished = chapters.filter(
(chapter) => chapter.state === "done" || chapter.state === "failed",
).length;
const total = chapters.length;
const hasFailure = chapters.some((chapter) => chapter.state === "failed");
if (stage === "finishing") {
return { stage, headline: "正在整理成文", chapters, finished, total, hasFailure };
}
if (stage === "writing" && total > 0) {
return {
stage,
headline: `已完成 ${finished} / ${total} 章`,
chapters,
finished,
total,
hasFailure,
};
}
// Either the run has not reached the chapters yet, or the section rows are
// not readable this poll. Both are "preparing" as far as the reader is
// concerned; we never invent a chapter count.
return {
stage: "preparing",
headline: "正在准备你的星盘证据",
chapters: [],
finished: 0,
total: 0,
hasFailure: false,
};
}
/**
* A signature that changes exactly when observable progress changes. The
* waiting screen restarts its stall timer on a new signature, so a chapter
* being retried (phase and percent unchanged) is what trips the slow notice.
*/
export function reportProgressSignature(input: Readonly<{
phase?: string;
percent?: number;
sections?: readonly ReportSectionProgress[];
}>): string {
const sections = (input.sections ?? [])
.map((section) => `${section.id}:${section.state}`)
.join(",");
return `${input.phase ?? ""}|${input.percent ?? ""}|${sections}`;
}
+31 -2
View File
@@ -26,6 +26,10 @@ import {
} from "./personal-report-generation";
import { REPORT_STABLE_CODES } from "./personal-report-codes";
import { summarizePersonalReportFailure } from "./personal-report-failure-summary";
import {
deriveSectionProgressState,
type ReportSectionProgress,
} from "./personal-report-progress";
import type { ReportBillingPort } from "./personal-report-billing";
import { checkSameOrigin } from "./personal-report-entitlement";
import type { PersonalReportJobRecord, PersonalReportJobService } from "./personal-report-job-service-core";
@@ -138,6 +142,7 @@ export function reportView(
row: PersonalReportRecord,
job?: PersonalReportJobRecord | null,
failure?: ReturnType<typeof summarizePersonalReportFailure> | null,
sections?: readonly ReportSectionProgress[] | null,
) {
return {
id: row.id,
@@ -150,6 +155,7 @@ export function reportView(
createdAt: row.createdAt,
completedAt: row.completedAt,
...(job ? { progressPercent: job.progressPercent, progressPhase: job.progressPhase } : {}),
...(sections && sections.length > 0 ? { sections } : {}),
...(failure?.summary ? { failureSummary: failure.summary } : {}),
...(failure?.innerReason ? { innerReason: failure.innerReason } : {}),
...(failure && failure.lastErrorCodes.length > 0 ? { sectionErrorCodes: failure.lastErrorCodes } : {}),
@@ -648,8 +654,15 @@ export type ReportReadCoreDeps = Readonly<{
document: unknown,
) => { ok: true; document: unknown } | { ok: false };
jobs?: Pick<PersonalReportJobService, "getOwnedByRequestId">;
/**
* Durable section rows. `sectionId` and `attemptCount` are read here to
* derive reader-visible chapter state and never leave the server as-is;
* see personal-report-progress.ts for the boundary.
*/
listSections?: (userId: string, requestId: string) => Promise<readonly Readonly<{
sectionId?: string;
status: string;
attemptCount?: number;
lastErrorCode: string | null;
}>[]>;
loadLongformMarkdown?: (input: Readonly<{
@@ -688,14 +701,30 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
const appendix = deps.loadLongformAppendix
? await deps.loadLongformAppendix({ userId: deps.userId, reportId: deps.reportId })
: null;
// Section rows back two different readers: the failure summary, and the
// chapter progress the waiting screen shows while the run is still going.
// A ready report needs neither, so it keeps its single-query read path.
const sectionRows = (row.status === "failed" || row.status === "generating") && deps.listSections
? await deps.listSections(deps.userId, row.requestId)
: [];
const failure = row.status === "failed"
? summarizePersonalReportFailure({
themeCount: row.requestedThemes.length,
sections: deps.listSections ? await deps.listSections(deps.userId, row.requestId) : [],
sections: sectionRows,
failureCode: row.failureCode,
appendixLastErrorCode: appendix?.lastErrorCode ?? null,
})
: null;
const sectionProgress: readonly ReportSectionProgress[] | null = row.status === "generating"
? sectionRows.flatMap((section) => (
typeof section.sectionId === "string" && section.sectionId.length > 0
? [{
id: section.sectionId,
state: deriveSectionProgressState(section.status, section.attemptCount ?? 0),
}]
: []
))
: null;
if (row.status === "ready") {
const markdownFromAppendix = appendix?.status === "ready" && appendix.markdown?.trim()
? appendix.markdown
@@ -742,7 +771,7 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
body: { report: reportView(row, job), reportDocument: validated.document },
};
}
return { status: 200, body: { report: reportView(row, job, failure) } };
return { status: 200, body: { report: reportView(row, job, failure, sectionProgress) } };
}
export type ReportDeleteCoreDeps = Readonly<{