改造一键流式优化首页
This commit is contained in:
+193
-2
@@ -121,9 +121,10 @@ h3 {
|
||||
}
|
||||
|
||||
.workflow-grid {
|
||||
align-items: start;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: minmax(22rem, 0.86fr) minmax(0, 1.14fr);
|
||||
}
|
||||
|
||||
.panel {
|
||||
@@ -134,6 +135,19 @@ h3 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.compact-panel {
|
||||
min-height: auto;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.input-panel {
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.fact-card-panel {
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
@@ -158,8 +172,16 @@ h3 {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.body-input {
|
||||
min-height: 14rem;
|
||||
min-height: 20rem;
|
||||
}
|
||||
|
||||
.two-col {
|
||||
@@ -256,6 +278,141 @@ h3 {
|
||||
padding: 0.25rem 0.55rem;
|
||||
}
|
||||
|
||||
.warn-tag {
|
||||
background: #fff7e6;
|
||||
border-color: #f0c978;
|
||||
color: #7b560b;
|
||||
}
|
||||
|
||||
.fact-summary-grid {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.fact-summary-grid div {
|
||||
background: #f4faf8;
|
||||
border: 1px solid #d9ebe6;
|
||||
border-radius: 6px;
|
||||
display: grid;
|
||||
gap: 0.22rem;
|
||||
min-width: 0;
|
||||
padding: 0.55rem;
|
||||
}
|
||||
|
||||
.fact-summary-grid span {
|
||||
color: #667085;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fact-summary-grid strong {
|
||||
color: #172033;
|
||||
font-size: 0.9rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fact-compact-list,
|
||||
.fact-edit-grid {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.fact-edit-grid {
|
||||
border-top: 1px solid #e5e9f0;
|
||||
padding-top: 0.7rem;
|
||||
}
|
||||
|
||||
.optimized-panel {
|
||||
min-height: 24rem;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.optimized-panel.is-streaming {
|
||||
border-color: #91cdbf;
|
||||
box-shadow: 0 0 0 3px rgba(38, 138, 116, 0.08);
|
||||
}
|
||||
|
||||
.stream-status {
|
||||
align-items: center;
|
||||
color: #0d6b58;
|
||||
display: inline-flex;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.stream-cursor {
|
||||
animation: stream-spin 0.8s linear infinite;
|
||||
border: 2px solid #b7ddd5;
|
||||
border-radius: 999px;
|
||||
border-top-color: #0d6b58;
|
||||
display: inline-block;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.streaming-preview {
|
||||
border-left-color: #20846d;
|
||||
}
|
||||
|
||||
.streaming-preview pre {
|
||||
animation: preview-breathe 1.8s ease-in-out infinite;
|
||||
background: #f4fbf8;
|
||||
border: 1px solid #d6eee8;
|
||||
}
|
||||
|
||||
.stream-placeholder {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.preview-pulse-line,
|
||||
.preview-pulse-block {
|
||||
animation: preview-breathe 1.6s ease-in-out infinite;
|
||||
background: linear-gradient(90deg, #edf5f2, #dceee8, #edf5f2);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.preview-pulse-line {
|
||||
height: 1rem;
|
||||
width: 82%;
|
||||
}
|
||||
|
||||
.preview-pulse-line.title-line {
|
||||
height: 1.5rem;
|
||||
width: 54%;
|
||||
}
|
||||
|
||||
.preview-pulse-line.short-line {
|
||||
width: 66%;
|
||||
}
|
||||
|
||||
.preview-pulse-block {
|
||||
height: 12rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stream-word-row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
min-height: 1.8rem;
|
||||
}
|
||||
|
||||
.stream-word {
|
||||
animation: word-pop 0.46s ease both;
|
||||
background: #e9f6f2;
|
||||
border: 1px solid #b7ddd5;
|
||||
border-radius: 999px;
|
||||
color: #0d6b58;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
padding: 0.22rem 0.5rem;
|
||||
}
|
||||
|
||||
.qa-list {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
@@ -348,12 +505,46 @@ h3 {
|
||||
@media (max-width: 900px) {
|
||||
.workflow-grid,
|
||||
.two-col,
|
||||
.fact-summary-grid,
|
||||
.calibration-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.input-panel {
|
||||
grid-row: auto;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stream-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes preview-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes word-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(0.4rem) scale(0.9);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
+124
-174
@@ -6,20 +6,21 @@ import {
|
||||
ArticleInputForm,
|
||||
type ArticleInputPayload,
|
||||
} from "../components/article-input-form";
|
||||
import {
|
||||
FactCardEditor,
|
||||
toConfirmedFactCard,
|
||||
} from "../components/fact-card-editor";
|
||||
import { FactCardEditor } from "../components/fact-card-editor";
|
||||
import { OptimizedPreview } from "../components/optimized-preview";
|
||||
import { PerformanceCalibrationPanel } from "../components/performance-calibration-panel";
|
||||
import { ProgressPanel } from "../components/progress-panel";
|
||||
import { QaReportPanel } from "../components/qa-report-panel";
|
||||
import type {
|
||||
CandidateFactCard,
|
||||
OptimizationFactCard,
|
||||
OptimizedArticle,
|
||||
QaReport,
|
||||
} from "../lib/domain/types";
|
||||
import type { ProgressAction } from "../lib/progress/progress";
|
||||
import {
|
||||
parseOptimizationStreamChunk,
|
||||
type OptimizationStreamEvent,
|
||||
} from "../lib/workflow/stream-events";
|
||||
|
||||
const initialInput: ArticleInputPayload = {
|
||||
title: "",
|
||||
@@ -31,7 +32,6 @@ const initialInput: ArticleInputPayload = {
|
||||
|
||||
interface ApiErrorResponse {
|
||||
error?: string;
|
||||
timing?: TimingSummary;
|
||||
}
|
||||
|
||||
interface TimingStep {
|
||||
@@ -44,42 +44,11 @@ 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;
|
||||
timing?: TimingSummary;
|
||||
}
|
||||
|
||||
interface ConfirmFactCardResponse extends ApiErrorResponse {
|
||||
factCard: CandidateFactCard;
|
||||
}
|
||||
|
||||
interface OptimizeJobResponse extends ApiErrorResponse {
|
||||
optimizedArticle: OptimizedArticle;
|
||||
qaReport: QaReport;
|
||||
timing?: TimingSummary;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [input, setInput] = useState(initialInput);
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [factCard, setFactCard] = useState<CandidateFactCard | null>(null);
|
||||
const [factCard, setFactCard] = useState<OptimizationFactCard | null>(null);
|
||||
const [draftArticle, setDraftArticle] = useState<OptimizedArticle | null>(null);
|
||||
const [optimizedArticle, setOptimizedArticle] =
|
||||
useState<OptimizedArticle | null>(null);
|
||||
const [qaReport, setQaReport] = useState<QaReport | null>(null);
|
||||
@@ -88,10 +57,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 canOptimize =
|
||||
Boolean(jobId) && Boolean(factCard) && factCard?.uncertain_items.length === 0;
|
||||
const [streamActivity, setStreamActivity] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!busyAction) return;
|
||||
@@ -102,127 +68,98 @@ 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]);
|
||||
|
||||
function startBusyAction(action: ProgressAction) {
|
||||
setElapsedSeconds(0);
|
||||
setBusyAction(action);
|
||||
}
|
||||
|
||||
async function analyze() {
|
||||
startBusyAction("analyze");
|
||||
setMessage("");
|
||||
setLastTiming(null);
|
||||
setLiveProgress(null);
|
||||
setOptimizedArticle(null);
|
||||
setQaReport(null);
|
||||
try {
|
||||
const response = await fetch("/api/jobs", {
|
||||
method: "POST",
|
||||
headers: apiHeaders(apiAccessKey),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = (await response.json()) as CreateJobResponse;
|
||||
if (!response.ok) throw new ApiResponseError(body.error ?? "分析失败", body.timing);
|
||||
setJobId(body.job.id);
|
||||
setFactCard(body.candidateFactCard);
|
||||
setLastTiming(body.timing ?? null);
|
||||
setMessage(
|
||||
body.timing
|
||||
? `候选事实卡已生成,用时 ${formatTiming(body.timing.total_ms)}。`
|
||||
: "候选事实卡已生成。",
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiResponseError) {
|
||||
setLastTiming(error.timing ?? null);
|
||||
}
|
||||
setMessage(error instanceof Error ? error.message : "分析失败");
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmFactCard() {
|
||||
if (!jobId || !factCard) return;
|
||||
startBusyAction("confirm");
|
||||
setMessage("");
|
||||
setLastTiming(null);
|
||||
try {
|
||||
const response = await fetch(`/api/jobs/${jobId}/confirm-fact-card`, {
|
||||
method: "POST",
|
||||
headers: apiHeaders(apiAccessKey),
|
||||
body: JSON.stringify(toConfirmedFactCard(factCard)),
|
||||
});
|
||||
const body = (await response.json()) as ConfirmFactCardResponse;
|
||||
if (!response.ok) throw new Error(body.error ?? "确认失败");
|
||||
setFactCard(body.factCard);
|
||||
setMessage("事实卡已确认。");
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "确认失败");
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function optimize() {
|
||||
if (!jobId) return;
|
||||
startBusyAction("optimize");
|
||||
setMessage("");
|
||||
setLastTiming(null);
|
||||
setLiveProgress(null);
|
||||
setDraftArticle(null);
|
||||
setOptimizedArticle(null);
|
||||
setQaReport(null);
|
||||
setStreamActivity("正在创建任务");
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/jobs/${jobId}/optimize`, {
|
||||
const payload: ArticleInputPayload & {
|
||||
fact_card?: OptimizationFactCard;
|
||||
} = {
|
||||
...input,
|
||||
title: "",
|
||||
};
|
||||
if (factCard) {
|
||||
payload.fact_card = factCard;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/jobs/optimize-stream", {
|
||||
method: "POST",
|
||||
headers: apiHeaders(apiAccessKey),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = (await response.json()) as OptimizeJobResponse;
|
||||
if (!response.ok) throw new ApiResponseError(body.error ?? "优化失败", body.timing);
|
||||
setOptimizedArticle(body.optimizedArticle);
|
||||
setQaReport(body.qaReport);
|
||||
setLastTiming(body.timing ?? null);
|
||||
setLiveProgress(null);
|
||||
const timingText = body.timing
|
||||
? `用时 ${formatTiming(body.timing.total_ms)}。`
|
||||
: "";
|
||||
setMessage(
|
||||
body.qaReport.overall_status === "fail"
|
||||
? `质检发现需要复核的问题,已保留导出文件。${timingText}`
|
||||
: `优化完成。${timingText}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiResponseError) {
|
||||
setLastTiming(error.timing ?? null);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = (await response.json()) as ApiErrorResponse;
|
||||
throw new Error(body.error ?? "优化失败");
|
||||
}
|
||||
|
||||
await readOptimizationStream(response, handleStreamEvent);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "优化失败");
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleStreamEvent(event: OptimizationStreamEvent) {
|
||||
switch (event.type) {
|
||||
case "job_created":
|
||||
setJobId(event.job.id);
|
||||
setStreamActivity("任务已创建");
|
||||
break;
|
||||
case "fact_card_ready":
|
||||
setFactCard(event.fact_card);
|
||||
setStreamActivity("事实卡已就绪");
|
||||
break;
|
||||
case "draft_started":
|
||||
setStreamActivity(event.message);
|
||||
break;
|
||||
case "draft_ready":
|
||||
setDraftArticle(event.article);
|
||||
setStreamActivity("草稿已生成");
|
||||
break;
|
||||
case "qa_started":
|
||||
setStreamActivity(event.message);
|
||||
break;
|
||||
case "qa_ready":
|
||||
setQaReport(event.qa_report);
|
||||
setStreamActivity("质量检查完成");
|
||||
break;
|
||||
case "rewrite_started":
|
||||
setStreamActivity(`正在修复第 ${event.round} 轮`);
|
||||
break;
|
||||
case "rewrite_ready":
|
||||
setDraftArticle(event.article);
|
||||
setStreamActivity(`第 ${event.round} 轮修复完成`);
|
||||
break;
|
||||
case "final_ready":
|
||||
setOptimizedArticle(event.optimized_article);
|
||||
setDraftArticle(null);
|
||||
setQaReport(event.qa_report);
|
||||
setStreamActivity("终稿已生成");
|
||||
setMessage(
|
||||
event.qa_report.overall_status === "fail"
|
||||
? "优化完成,质检发现需要复核的问题。"
|
||||
: "优化完成。",
|
||||
);
|
||||
break;
|
||||
case "failed":
|
||||
setStreamActivity("优化失败");
|
||||
throw new Error(event.error);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<header className="topbar">
|
||||
@@ -239,35 +176,30 @@ export default function Home() {
|
||||
value={apiAccessKey}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
disabled={!canOptimize || busyAction === "optimize"}
|
||||
onClick={optimize}
|
||||
type="button"
|
||||
>
|
||||
{busyAction === "optimize" ? "优化中..." : "开始优化"}
|
||||
</button>
|
||||
</header>
|
||||
<ProgressPanel
|
||||
action={busyAction}
|
||||
elapsedSeconds={elapsedSeconds}
|
||||
lastTiming={lastTiming}
|
||||
liveProgress={liveProgress}
|
||||
liveProgress={null}
|
||||
/>
|
||||
<div className="workflow-grid">
|
||||
<ArticleInputForm
|
||||
isSubmitting={busyAction === "analyze"}
|
||||
isSubmitting={busyAction === "optimize"}
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={analyze}
|
||||
onSubmit={optimize}
|
||||
/>
|
||||
<FactCardEditor
|
||||
factCard={factCard}
|
||||
isSaving={busyAction === "confirm"}
|
||||
isSaving={busyAction === "optimize"}
|
||||
onChange={setFactCard}
|
||||
onConfirm={confirmFactCard}
|
||||
/>
|
||||
<OptimizedPreview
|
||||
activityText={streamActivity}
|
||||
article={optimizedArticle}
|
||||
draftArticle={draftArticle}
|
||||
isStreaming={busyAction === "optimize"}
|
||||
jobId={jobId}
|
||||
/>
|
||||
<QaReportPanel report={qaReport} />
|
||||
@@ -282,6 +214,42 @@ export default function Home() {
|
||||
);
|
||||
}
|
||||
|
||||
async function readOptimizationStream(
|
||||
response: Response,
|
||||
onEvent: (event: OptimizationStreamEvent) => void,
|
||||
) {
|
||||
if (!response.body) {
|
||||
throw new Error("当前浏览器不支持流式响应");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let remainder = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const parsed = parseOptimizationStreamChunk(
|
||||
remainder,
|
||||
decoder.decode(value, { stream: true }),
|
||||
);
|
||||
remainder = parsed.remainder;
|
||||
parsed.events.forEach(onEvent);
|
||||
}
|
||||
|
||||
const finalText = decoder.decode();
|
||||
if (finalText) {
|
||||
const parsed = parseOptimizationStreamChunk(remainder, finalText);
|
||||
remainder = parsed.remainder;
|
||||
parsed.events.forEach(onEvent);
|
||||
}
|
||||
|
||||
if (remainder.trim().length > 0) {
|
||||
onEvent(JSON.parse(remainder) as OptimizationStreamEvent);
|
||||
}
|
||||
}
|
||||
|
||||
function apiHeaders(apiAccessKey: string) {
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
if (apiAccessKey) {
|
||||
@@ -289,21 +257,3 @@ function apiHeaders(apiAccessKey: string) {
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function formatTiming(milliseconds: number) {
|
||||
const seconds = Math.round(milliseconds / 1000);
|
||||
if (seconds < 60) return `${seconds} 秒`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes} 分 ${remainingSeconds} 秒`;
|
||||
}
|
||||
|
||||
class ApiResponseError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly timing?: TimingSummary,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiResponseError";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user