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:
co-authored by
Claude Fable 5
parent
301eae4b76
commit
848e39e61f
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user