feat: show backend optimization progress

This commit is contained in:
Codex
2026-06-21 23:43:03 +08:00
parent 5a627bcaa8
commit 21aa75d240
15 changed files with 549 additions and 43 deletions
+41
View File
@@ -21,6 +21,7 @@ vi.mock("../../../lib/llm/client", async () => {
import { POST as confirmFactCard } from "../jobs/[jobId]/confirm-fact-card/route";
import { GET as downloadExport } from "../jobs/[jobId]/exports/[fileName]/route";
import { POST as optimizeJob } from "../jobs/[jobId]/optimize/route";
import { GET as getJobProgress } from "../jobs/[jobId]/progress/route";
import { POST as createJob } from "../jobs/route";
const validFactCard = {
@@ -236,10 +237,50 @@ describe("job API routes", () => {
expect(body.timing.steps.map((step) => step.label)).toEqual([
"生成优化稿",
"质量检查",
"整理结果",
]);
expect(body.timing.steps.every((step) => step.duration_ms >= 0)).toBe(true);
});
it("returns backend-visible optimization progress after a job runs", async () => {
const { job } = await createJobFixture();
await confirmFactCard(
request(validFactCard),
params<{ jobId: string }>({ jobId: job.id }),
);
llmMocks.generateValidatedJson
.mockResolvedValueOnce({
title: "API LLM Optimized GEO Article",
summary:
"A official site article for Marketing teams about GEO optimization.",
body_markdown:
"Example Technology Co., Ltd. has 8 years of GEO optimization experience.",
image_suggestions: [],
changed_sections: ["title", "body"],
requires_user_confirmation: [],
})
.mockResolvedValueOnce({ checks: [] });
await optimizeJob(request({}), params<{ jobId: string }>({ jobId: job.id }));
const response = await getJobProgress(
request({}),
params<{ jobId: string }>({ jobId: job.id }),
);
const body = (await response.json()) as {
progress: { status: string; steps: Array<{ label: string; status: string }> };
};
expect(response.status).toBe(200);
expect(body.progress.status).toBe("completed");
expect(body.progress.steps.map((step) => step.label)).toEqual([
"生成优化稿",
"质量检查",
"整理结果",
]);
});
it("uses mocked LLM article output during optimize route", async () => {
const { job } = await createJobFixture();
await confirmFactCard(
@@ -5,6 +5,10 @@ import { getRepositoryFromRuntime } from "../../../../../lib/db/repository";
import { LlmValidationError } from "../../../../../lib/llm/client";
import { getExportStoreFromRuntime } from "../../../../../lib/workflow/export-store";
import { runOptimizationWorkflow } from "../../../../../lib/workflow/orchestrator";
import {
recordWorkflowProgress,
startWorkflowProgress,
} from "../../../../../lib/workflow/progress-store";
interface RouteContext {
params: Promise<{ jobId: string }>;
@@ -32,6 +36,7 @@ export async function POST(request: Request, context: RouteContext) {
}
const requestStartedAt = Date.now();
startWorkflowProgress(jobId);
try {
const result = await runOptimizationWorkflow({
input: {
@@ -42,6 +47,9 @@ export async function POST(request: Request, context: RouteContext) {
user_instructions: job.user_instructions,
},
factCard: factCardRecord,
onProgress: (event) => {
recordWorkflowProgress(jobId, event);
},
});
const optimizedArticle = await repository.saveOptimizedArticle(
jobId,
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { requireApiAccess } from "../../../../../lib/api/auth";
import { getWorkflowProgress } from "../../../../../lib/workflow/progress-store";
interface RouteContext {
params: Promise<{ jobId: string }>;
}
export async function GET(request: Request, context: RouteContext) {
const access = requireApiAccess(request);
if (!access.ok) {
return access.response;
}
const { jobId } = await context.params;
return NextResponse.json({ progress: getWorkflowProgress(jobId) });
}
+47
View File
@@ -43,6 +43,22 @@ interface TimingSummary {
steps: TimingStep[];
}
interface ProgressStep {
label: string;
status: "running" | "completed" | "failed";
duration_ms?: number;
}
interface WorkflowProgress {
current_step: string | null;
status: "idle" | "running" | "completed" | "failed";
steps: ProgressStep[];
}
interface ProgressResponse extends ApiErrorResponse {
progress: WorkflowProgress;
}
interface CreateJobResponse extends ApiErrorResponse {
job: { id: string };
candidateFactCard: CandidateFactCard;
@@ -71,6 +87,7 @@ export default function Home() {
const [apiAccessKey, setApiAccessKey] = useState("");
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [lastTiming, setLastTiming] = useState<TimingSummary | null>(null);
const [liveProgress, setLiveProgress] = useState<WorkflowProgress | null>(null);
const exportBlocked = useMemo(
() => qaReport?.overall_status === "fail",
@@ -89,10 +106,37 @@ export default function Home() {
return () => window.clearInterval(timer);
}, [busyAction]);
useEffect(() => {
if (busyAction !== "optimize" || !jobId) return;
let cancelled = false;
async function pollProgress() {
try {
const response = await fetch(`/api/jobs/${jobId}/progress`, {
headers: apiHeaders(apiAccessKey),
});
const body = (await response.json()) as ProgressResponse;
if (!cancelled && response.ok) {
setLiveProgress(body.progress);
}
} catch {
// The main optimization request still owns user-facing errors.
}
}
void pollProgress();
const timer = window.setInterval(pollProgress, 2000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [apiAccessKey, busyAction, jobId]);
async function analyze() {
setBusyAction("analyze");
setMessage("");
setLastTiming(null);
setLiveProgress(null);
setOptimizedArticle(null);
setQaReport(null);
try {
@@ -148,6 +192,7 @@ export default function Home() {
setBusyAction("optimize");
setMessage("");
setLastTiming(null);
setLiveProgress(null);
try {
const response = await fetch(`/api/jobs/${jobId}/optimize`, {
method: "POST",
@@ -158,6 +203,7 @@ export default function Home() {
setOptimizedArticle(body.optimizedArticle);
setQaReport(body.qaReport);
setLastTiming(body.timing ?? null);
setLiveProgress(null);
const timingText = body.timing
? `用时 ${formatTiming(body.timing.total_ms)}`
: "";
@@ -204,6 +250,7 @@ export default function Home() {
action={busyAction as ProgressAction | null}
elapsedSeconds={elapsedSeconds}
lastTiming={lastTiming}
liveProgress={liveProgress}
/>
<div className="workflow-grid">
<ArticleInputForm