From 67f19f855094aba6fe838f56486da298a73b7d21 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 16 Jun 2026 13:46:04 +0800 Subject: [PATCH] feat: build local optimizer interface --- src/app/globals.css | 240 +++++++++++++++++++++++++- src/app/page.tsx | 164 +++++++++++++++--- src/components/article-input-form.tsx | 103 +++++++++++ src/components/fact-card-editor.tsx | 133 ++++++++++++++ src/components/optimized-preview.tsx | 83 +++++++++ src/components/qa-report-panel.tsx | 44 +++++ 6 files changed, 745 insertions(+), 22 deletions(-) create mode 100644 src/components/article-input-form.tsx create mode 100644 src/components/fact-card-editor.tsx create mode 100644 src/components/optimized-preview.tsx create mode 100644 src/components/qa-report-panel.tsx diff --git a/src/app/globals.css b/src/app/globals.css index 3266f7b..cfb0661 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -4,8 +4,8 @@ :root { color-scheme: light; - background: #f8fafc; - color: #111827; + background: #f6f7f9; + color: #172033; } * { @@ -20,3 +20,239 @@ body { Helvetica, sans-serif; } + +button, +input, +select, +textarea { + font: inherit; +} + +button, +.download-link { + border: 1px solid #172033; + background: #172033; + color: #ffffff; + cursor: pointer; + border-radius: 6px; + min-height: 2.25rem; + padding: 0.45rem 0.8rem; + text-decoration: none; + text-align: center; + white-space: nowrap; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +input, +select, +textarea { + width: 100%; + border: 1px solid #cbd3df; + border-radius: 6px; + background: #ffffff; + color: #172033; + padding: 0.65rem 0.7rem; +} + +textarea { + min-height: 5rem; + resize: vertical; +} + +label { + display: grid; + gap: 0.35rem; +} + +label span, +h3 { + color: #586174; + font-size: 0.82rem; + font-weight: 700; + text-transform: uppercase; +} + +.app-shell { + display: grid; + gap: 1rem; + min-height: 100vh; + padding: 1rem; +} + +.topbar { + align-items: center; + display: flex; + gap: 1rem; + justify-content: space-between; + border-bottom: 1px solid #dce2eb; + padding-bottom: 1rem; +} + +.topbar h1 { + margin: 0; + color: #172033; + font-size: clamp(1.45rem, 3vw, 2.1rem); + line-height: 1.1; +} + +.topbar p { + margin: 0.3rem 0 0; + color: #586174; +} + +.workflow-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.panel { + border: 1px solid #dce2eb; + border-radius: 8px; + background: #ffffff; + min-height: 18rem; + padding: 1rem; +} + +.stack { + display: grid; + align-content: start; + gap: 0.9rem; +} + +.empty-panel { + color: #8992a3; +} + +.panel-heading, +.qa-title-row { + align-items: center; + display: flex; + gap: 0.75rem; + justify-content: space-between; +} + +.panel-heading { + color: #172033; + font-size: 1rem; + font-weight: 800; +} + +.body-input { + min-height: 14rem; +} + +.two-col { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.status-text { + margin: 0; + font-weight: 700; +} + +.status-pill { + border-radius: 999px; + display: inline-flex; + font-size: 0.72rem; + font-weight: 800; + line-height: 1; + padding: 0.35rem 0.5rem; + text-transform: uppercase; +} + +.pass { + background: #e6f6ec; + color: #116438; +} + +.warn { + background: #fff5d6; + color: #805a00; +} + +.fail { + background: #fde7e7; + color: #9f1d21; +} + +.preview { + border-left: 3px solid #2f6fdd; + padding-left: 1rem; +} + +.preview h2 { + margin: 0; + font-size: 1.25rem; +} + +.preview p { + color: #586174; +} + +.preview pre { + background: #f6f7f9; + border-radius: 6px; + overflow: auto; + padding: 0.8rem; + white-space: pre-wrap; +} + +.tag-row, +.export-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.tag { + border: 1px solid #cbd3df; + border-radius: 999px; + color: #586174; + padding: 0.25rem 0.55rem; +} + +.qa-list { + display: grid; + gap: 0.7rem; +} + +.qa-item { + border: 1px solid #e5e9f0; + border-radius: 8px; + display: grid; + gap: 0.45rem; + padding: 0.75rem; +} + +.qa-item p, +.qa-item em, +.qa-item small { + margin: 0; +} + +.qa-item small { + color: #586174; +} + +.qa-item code { + color: #2f6fdd; +} + +@media (max-width: 900px) { + .workflow-grid, + .two-col { + grid-template-columns: 1fr; + } + + .topbar { + align-items: stretch; + flex-direction: column; + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index eb5e973..34e98eb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,26 +1,150 @@ -const sections = [ - "Article Input", - "Fact Card", - "Optimized Result", - "Quality Report", -]; +"use client"; + +import { useMemo, useState } from "react"; + +import { + ArticleInputForm, + type ArticleInputPayload, +} from "../components/article-input-form"; +import { + FactCardEditor, + toConfirmedFactCard, +} from "../components/fact-card-editor"; +import { OptimizedPreview } from "../components/optimized-preview"; +import { QaReportPanel } from "../components/qa-report-panel"; +import type { + CandidateFactCard, + OptimizedArticle, + QaReport, +} from "../lib/domain/types"; + +const initialInput: ArticleInputPayload = { + title: "", + body: "", + image_lines: "", + platform: "official_site", + user_instructions: "", +}; export default function Home() { + const [input, setInput] = useState(initialInput); + const [jobId, setJobId] = useState(null); + const [factCard, setFactCard] = useState(null); + const [optimizedArticle, setOptimizedArticle] = + useState(null); + const [qaReport, setQaReport] = useState(null); + const [busyAction, setBusyAction] = useState(null); + const [message, setMessage] = useState(""); + + const exportBlocked = useMemo( + () => qaReport?.overall_status === "fail", + [qaReport], + ); + const canOptimize = + Boolean(jobId) && Boolean(factCard) && factCard?.uncertain_items.length === 0; + + async function analyze() { + setBusyAction("analyze"); + setMessage(""); + setOptimizedArticle(null); + setQaReport(null); + try { + const response = await fetch("/api/jobs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }); + const body = await response.json(); + if (!response.ok) throw new Error(body.error ?? "Analyze failed"); + setJobId(body.job.id); + setFactCard(body.candidateFactCard); + setMessage("Candidate fact card created."); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Analyze failed"); + } finally { + setBusyAction(null); + } + } + + async function confirmFactCard() { + if (!jobId || !factCard) return; + setBusyAction("confirm"); + setMessage(""); + try { + const response = await fetch(`/api/jobs/${jobId}/confirm-fact-card`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(toConfirmedFactCard(factCard)), + }); + const body = await response.json(); + if (!response.ok) throw new Error(body.error ?? "Confirm failed"); + setFactCard(body.factCard); + setMessage("Fact card confirmed."); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Confirm failed"); + } finally { + setBusyAction(null); + } + } + + async function optimize() { + if (!jobId) return; + setBusyAction("optimize"); + setMessage(""); + try { + const response = await fetch(`/api/jobs/${jobId}/optimize`, { + method: "POST", + }); + const body = await response.json(); + if (!response.ok) throw new Error(body.error ?? "Optimize failed"); + setOptimizedArticle(body.optimizedArticle); + setQaReport(body.qaReport); + setMessage( + body.qaReport.overall_status === "fail" + ? "QA found hard failures; export is blocked." + : "Optimization complete.", + ); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Optimize failed"); + } finally { + setBusyAction(null); + } + } + return ( -
-

- GEO Agent Article Optimizer -

-
- {sections.map((section) => ( -
-

{section}

-
- ))} +
+
+
+

GEO Agent Article Optimizer

+ {message &&

{message}

} +
+ +
+
+ + + +
); diff --git a/src/components/article-input-form.tsx b/src/components/article-input-form.tsx new file mode 100644 index 0000000..9506ede --- /dev/null +++ b/src/components/article-input-form.tsx @@ -0,0 +1,103 @@ +"use client"; + +import type { FormEvent } from "react"; + +import type { PublishPlatform } from "../lib/domain/types"; + +export interface ArticleInputPayload { + title: string; + body: string; + image_lines: string; + platform: PublishPlatform; + user_instructions: string; +} + +interface ArticleInputFormProps { + value: ArticleInputPayload; + isSubmitting: boolean; + onChange: (value: ArticleInputPayload) => void; + onSubmit: () => void; +} + +const platforms: { value: PublishPlatform; label: string }[] = [ + { value: "official_site", label: "Official site" }, + { value: "media_article", label: "Media article" }, + { value: "comparison_review", label: "Comparison review" }, + { value: "recommendation_list", label: "Recommendation list" }, +]; + +export function ArticleInputForm({ + value, + isSubmitting, + onChange, + onSubmit, +}: ArticleInputFormProps) { + function update( + key: K, + nextValue: ArticleInputPayload[K], + ) { + onChange({ ...value, [key]: nextValue }); + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + onSubmit(); + } + + return ( +
+
+ Article Input + +
+ +