From 016b653b76c068346a9776268cb35ff48c6ea6ea Mon Sep 17 00:00:00 2001 From: czj <13261895355@163.com> Date: Wed, 1 Jul 2026 13:25:07 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B9=E9=80=A0=E4=B8=80=E9=94=AE=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E4=BC=98=E5=8C=96=E9=A6=96=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/globals.css | 195 ++++++++++++++++- src/app/page.tsx | 298 +++++++++++--------------- src/components/article-input-form.tsx | 16 +- src/components/fact-card-editor.tsx | 293 +++++++++++++++---------- src/components/optimized-preview.tsx | 170 ++++++++++----- 5 files changed, 621 insertions(+), 351 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 963a217..5bd4ec7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index d6ef980..e3b3192 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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(null); - const [factCard, setFactCard] = useState(null); + const [factCard, setFactCard] = useState(null); + const [draftArticle, setDraftArticle] = useState(null); const [optimizedArticle, setOptimizedArticle] = useState(null); const [qaReport, setQaReport] = useState(null); @@ -88,10 +57,7 @@ export default function Home() { const [apiAccessKey, setApiAccessKey] = useState(""); const [elapsedSeconds, setElapsedSeconds] = useState(0); const [lastTiming, setLastTiming] = useState(null); - const [liveProgress, setLiveProgress] = useState(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 (
@@ -239,35 +176,30 @@ export default function Home() { value={apiAccessKey} /> -
@@ -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 = { "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"; - } -} diff --git a/src/components/article-input-form.tsx b/src/components/article-input-form.tsx index 97698fa..49b0440 100644 --- a/src/components/article-input-form.tsx +++ b/src/components/article-input-form.tsx @@ -45,23 +45,15 @@ export function ArticleInputForm({ } return ( -
+
文章输入 -
-