feat: add real PDF ingestion workflow

This commit is contained in:
Codex
2026-08-02 00:29:40 +08:00
parent 0eb2a86f36
commit 4d97a30616
18 changed files with 986 additions and 93 deletions
+230 -86
View File
@@ -21,36 +21,76 @@ import {
UploadSimpleIcon,
WarningCircleIcon,
} from "@phosphor-icons/react";
import Image from "next/image";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { ChangeEvent, FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { SettingsCenter } from "@/components/settings-center";
import {
initialLayers,
initialMessages,
styleDirections,
workflowStages,
} from "@/lib/demo";
import type { ChatMessage } from "@/types/workflow";
import { styleDirections } from "@/lib/demo";
import type { ChatMessage, PlanLayer, ProjectSnapshot, WorkflowStage } from "@/types/workflow";
import type { SettingsReadiness } from "@/types/settings";
const iconWeight = "regular" as const;
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
const stageMeta: Array<Pick<WorkflowStage, "id" | "label" | "detail">> = [
{ id: "uploaded", label: "导入图纸", detail: "PDF 原文件" },
{ id: "region_selection", label: "选择户型", detail: "候选区域" },
{ id: "plan_review", label: "确认结构", detail: "墙、门窗与比例" },
{ id: "blockout", label: "生成白模", detail: "空间骨架" },
{ id: "style_brief", label: "明确风格", detail: "Style DNA" },
{ id: "direction_selection", label: "选择方向", detail: "三个方案" },
{ id: "render_review", label: "审阅效果", detail: "多视角" },
{ id: "editing", label: "多轮修改", detail: "局部编辑" },
];
export function WorkflowStudio() {
const [layers, setLayers] = useState(initialLayers);
const [project, setProject] = useState<ProjectSnapshot | null>(null);
const [layers, setLayers] = useState<PlanLayer[]>([]);
const [selectedDirection, setSelectedDirection] = useState("quiet-modern");
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState("");
const [furnitureMode, setFurnitureMode] = useState<"reference" | "remove">("reference");
const [settingsOpen, setSettingsOpen] = useState(false);
const [readiness, setReadiness] = useState<SettingsReadiness | null>(null);
const [uploading, setUploading] = useState(false);
const [workflowError, setWorkflowError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const currentDirection = useMemo(
() => styleDirections.find((item) => item.id === selectedDirection) ?? styleDirections[0],
[selectedDirection],
);
const workflowStages = useMemo<WorkflowStage[]>(() => {
const activeIndex = project ? stageMeta.findIndex((item) => item.id === project.stage) : -1;
return stageMeta.map((stage, index) => ({
...stage,
status: activeIndex < 0 ? "pending" : index < activeIndex ? "complete" : index === activeIndex ? "active" : "pending",
}));
}, [project]);
const selectedRegion = useMemo(
() => project?.plan.regions.find((region) => region.id === project.plan.selected_region_id) ?? null,
[project],
);
function applyProject(next: ProjectSnapshot) {
setProject(next);
setLayers(next.plan.layers.map((layer) => ({ ...layer, count: layer.element_count ?? undefined })));
setMessages([
...next.plan.ingestion_notes.map((body, index) => ({
id: `ingestion-${index}`,
role: "assistant" as const,
body,
meta: "图纸解析",
})),
...next.plan.issues.filter((issue) => !issue.resolved).map((issue) => ({
id: `issue-${issue.id}`,
role: "assistant" as const,
body: issue.message,
meta: "等待确认",
})),
]);
}
useEffect(() => {
async function loadReadiness() {
try {
@@ -67,8 +107,73 @@ export function WorkflowStudio() {
}
}
void loadReadiness();
fetch(`${API_BASE}/v1/projects`, { cache: "no-store" })
.then((response) => response.ok ? response.json() as Promise<ProjectSnapshot[]> : [])
.then((projects) => {
const latest = projects.find((item) => item.project_id !== "demo-apartment");
if (latest) applyProject(latest);
})
.catch(() => undefined);
}, []);
async function readApiError(response: Response, fallback: string) {
try {
const payload = await response.json() as { detail?: string | { message?: string } };
if (typeof payload.detail === "string") return payload.detail;
if (payload.detail?.message) return payload.detail.message;
} catch {
// Keep the readable fallback.
}
return fallback;
}
async function uploadPlan(file: File) {
if (!readiness?.ready) {
setSettingsOpen(true);
setWorkflowError("请先完成系统设置和真实生图验证。");
return;
}
setUploading(true);
setWorkflowError(null);
const form = new FormData();
form.append("file", file);
form.append("project_name", `${file.name.replace(/\.pdf$/i, "")} 概念方案`);
try {
const response = await fetch(`${API_BASE}/v1/projects/ingest`, { method: "POST", body: form });
if (!response.ok) throw new Error(await readApiError(response, "图纸上传或解析失败。"));
applyProject(await response.json() as ProjectSnapshot);
} catch (error) {
setWorkflowError(error instanceof Error ? error.message : "图纸上传或解析失败。");
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
function chooseFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (file) void uploadPlan(file);
}
async function selectRegion(regionId: string) {
if (!project || project.stage !== "region_selection") return;
setWorkflowError(null);
const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/commands`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
command: "select_region",
expected_revision: project.revision,
payload: { region_id: regionId },
}),
});
if (!response.ok) {
setWorkflowError(await readApiError(response, "候选户型选择失败。"));
return;
}
applyProject(await response.json() as ProjectSnapshot);
}
function toggleLayer(id: string) {
setLayers((items) =>
items.map((item) => (item.id === id ? { ...item, visible: !item.visible } : item)),
@@ -102,7 +207,7 @@ export function WorkflowStudio() {
</div>
<div>
<p className="brand-name"></p>
<p className="brand-subtitle">11-2-104 </p>
<p className="brand-subtitle">{project?.name ?? "新建住宅概念方案"}</p>
</div>
</div>
@@ -116,12 +221,13 @@ export function WorkflowStudio() {
<button
className="button button-primary"
type="button"
disabled={!readiness?.ready}
onClick={() => { if (!readiness?.ready) setSettingsOpen(true); }}
title={!readiness?.ready ? "请先完成系统设置" : undefined}
disabled={uploading || Boolean(project)}
onClick={() => readiness?.ready ? fileInputRef.current?.click() : setSettingsOpen(true)}
title={!readiness?.ready ? "请先完成系统设置" : project ? "当前阶段请在图纸上完成候选区域确认" : undefined}
>
{readiness?.ready ? "确认结构" : "配置后开始"} <ArrowRightIcon size={16} weight="bold" />
{uploading ? "正在解析" : project ? "按图确认" : readiness?.ready ? "导入图纸" : "配置后开始"} <ArrowRightIcon size={16} weight="bold" />
</button>
<input ref={fileInputRef} className="visually-hidden" type="file" accept="application/pdf,.pdf" onChange={chooseFile} />
</div>
</header>
@@ -129,7 +235,7 @@ export function WorkflowStudio() {
<aside className="workflow-rail" aria-label="设计流程">
<div className="rail-heading">
<p></p>
<span>3 / 8</span>
<span>{project ? Math.max(1, stageMeta.findIndex((item) => item.id === project.stage) + 1) : 0} / 8</span>
</div>
<nav className="stage-list">
{workflowStages.map((stage) => (
@@ -152,7 +258,11 @@ export function WorkflowStudio() {
<div className="rail-note">
<WarningCircleIcon size={18} weight={iconWeight} />
<p> 2 </p>
<p>{project
? project.plan.issues.filter((issue) => !issue.resolved).length > 0
? `还有 ${project.plan.issues.filter((issue) => !issue.resolved).length} 项图纸问题需要确认。`
: "当前阶段的信息已确认。"
: "请先导入一份 PDF 户型图。"}</p>
</div>
</aside>
@@ -180,80 +290,107 @@ export function WorkflowStudio() {
</Tooltip.Trigger>
<Tooltip.Portal><Tooltip.Content className="tooltip"></Tooltip.Content></Tooltip.Portal>
</Tooltip.Root>
<button className="button button-secondary compact" type="button">
<UploadSimpleIcon size={16} />
<button className="button button-secondary compact" type="button" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
<UploadSimpleIcon size={16} /> {project ? "更换图纸" : "导入图纸"}
</button>
</div>
</div>
<Tabs.Content value="plan" className="tab-content">
<div className="plan-workspace">
<div className="plan-canvas">
<div className="canvas-labels">
<span></span>
<span> PDF · 43 CAD </span>
</div>
<div className="plan-image-frame">
<Image
src="/sample-plan.png"
alt="从真实 PDF 中分离出的住宅建筑结构平面图"
fill
priority
sizes="(max-width: 900px) 100vw, 60vw"
className="plan-image"
/>
<div className="region-outline" aria-hidden="true" />
<button className="issue-marker issue-one" type="button" aria-label="比例待确认">1</button>
<button className="issue-marker issue-two" type="button" aria-label="家具意图待确认">2</button>
</div>
<div className="canvas-status">
<span><CheckIcon size={14} weight="bold" /> </span>
<span></span>
</div>
{!project ? (
<div className="plan-upload-empty">
<div className="upload-emblem"><UploadSimpleIcon size={30} /></div>
<h2>{uploading ? "正在读取和分析图纸" : "导入第一份真实户型图"}</h2>
<p> 25 MB PDF</p>
<button className="button button-primary" type="button" onClick={() => fileInputRef.current?.click()} disabled={uploading || !readiness?.ready}>
{uploading ? <CircleNotchIcon className="spin" size={16} /> : <UploadSimpleIcon size={16} />}
{uploading ? "解析中,请稍候" : readiness?.ready ? "选择 PDF 图纸" : "完成设置后导入"}
</button>
{workflowError ? <p className="workflow-error"><WarningCircleIcon size={16} /> {workflowError}</p> : null}
</div>
<aside className="layer-panel">
<div className="panel-title-row">
<div>
<p className="panel-title"></p>
<p className="panel-caption"></p>
) : (
<div className="plan-workspace">
<div className="plan-canvas">
<div className="canvas-labels">
<span>{selectedRegion?.name ?? `${project.plan.regions.length} 个候选平面区域`}</span>
<span>{project.plan.vector_based ? "矢量 PDF" : "扫描 PDF"} · {project.plan.vector_element_count.toLocaleString()} </span>
</div>
<button className="icon-button small" type="button" aria-label="添加图层">
<PlusIcon size={16} />
</button>
</div>
<div className="layer-list">
{layers.map((layer) => (
<button
className={`layer-row ${layer.visible ? "is-visible" : ""}`}
key={layer.id}
type="button"
onClick={() => toggleLayer(layer.id)}
>
{layer.visible ? <EyeIcon size={17} /> : <EyeSlashIcon size={17} />}
<span>{layer.label}</span>
{layer.count ? <small>{layer.count}</small> : null}
</button>
))}
<div className="plan-document-frame">
{/* eslint-disable-next-line @next/next/no-img-element -- authenticated runtime preview URL. */}
<img
src={`${API_BASE}/v1/projects/${project.project_id}/preview`}
alt={`${project.name} 的 PDF 首页预览`}
className="plan-document-image"
/>
<div className="region-layer" aria-label="候选户型区域">
{project.plan.regions.map((region, index) => {
const [x0, y0, x1, y1] = region.bounds;
const selected = region.id === project.plan.selected_region_id;
return (
<button
className={`detected-region ${selected ? "selected" : ""} ${region.recommended ? "recommended" : ""}`}
key={region.id}
type="button"
style={{ left: `${x0 * 100}%`, top: `${y0 * 100}%`, width: `${(x1 - x0) * 100}%`, height: `${(y1 - y0) * 100}%` }}
onClick={() => void selectRegion(region.id)}
disabled={project.stage !== "region_selection"}
aria-label={`选择${region.name}`}
>
<span>{index + 1}</span>
<strong>{region.name}</strong>
<small>{region.recommended ? "建议选择 · " : ""}{Math.round(region.confidence * 100)}%</small>
</button>
);
})}
</div>
</div>
<div className="canvas-status">
<span><CheckIcon size={14} weight="bold" /> MinIO</span>
<span>{project.stage === "region_selection" ? "请选择目标户型" : "目标区域已确认"}</span>
</div>
{workflowError ? <p className="workflow-error"><WarningCircleIcon size={16} /> {workflowError}</p> : null}
</div>
<div className="panel-divider" />
<p className="panel-title"></p>
<div className="segmented-control">
<button
type="button"
className={furnitureMode === "reference" ? "active" : ""}
onClick={() => setFurnitureMode("reference")}
></button>
<button
type="button"
className={furnitureMode === "remove" ? "active" : ""}
onClick={() => setFurnitureMode("remove")}
></button>
</div>
<p className="helper-text"></p>
</aside>
</div>
<aside className="layer-panel">
<div className="panel-title-row">
<div>
<p className="panel-title"></p>
<p className="panel-caption">PDF · {project.plan.page_count} </p>
</div>
</div>
<div className="layer-list">
{layers.map((layer) => (
<button
className={`layer-row ${layer.visible ? "is-visible" : ""}`}
key={layer.id}
type="button"
onClick={() => toggleLayer(layer.id)}
>
{layer.visible ? <EyeIcon size={17} /> : <EyeSlashIcon size={17} />}
<span>{layer.label}</span>
{layer.count ? <small>{layer.count.toLocaleString()}</small> : null}
</button>
))}
</div>
<div className="panel-divider" />
<p className="panel-title"></p>
<div className="ingestion-facts">
<span><CheckIcon size={13} /> {project.plan.vector_based ? "矢量信息可用" : "需要视觉分割"}</span>
<span><CheckIcon size={13} /> {project.plan.ocr_used ? "百度 OCR 已参与" : "PDF 文字层可读取"}</span>
<span><WarningCircleIcon size={13} /> </span>
</div>
<div className="panel-divider" />
<p className="panel-title"></p>
<div className="segmented-control">
<button type="button" className={furnitureMode === "reference" ? "active" : ""} onClick={() => setFurnitureMode("reference")}></button>
<button type="button" className={furnitureMode === "remove" ? "active" : ""} onClick={() => setFurnitureMode("remove")}></button>
</div>
<p className="helper-text"></p>
</aside>
</div>
)}
</Tabs.Content>
<Tabs.Content value="blockout" className="tab-content empty-workspace">
@@ -271,6 +408,12 @@ export function WorkflowStudio() {
<section className="conversation" aria-label="设计对话">
<div className="message-stream">
{!project ? (
<article className="message message-assistant">
<span className="assistant-icon"><SparkleIcon size={15} weight="fill" /></span>
<div><small></small><p> PDF </p></div>
</article>
) : null}
{messages.slice(-3).map((message) => (
<article className={`message message-${message.role}`} key={message.id}>
{message.role === "assistant" ? (
@@ -290,9 +433,10 @@ export function WorkflowStudio() {
id="design-instruction"
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="例如:保留床和窗的位置,客厅更松弛,不要冷灰"
placeholder={project ? "例如:保留床和窗的位置,客厅更松弛,不要冷灰" : "导入图纸后开始对话"}
disabled={!project}
/>
<button className="send-button" type="submit" aria-label="发送设计指令">
<button className="send-button" type="submit" aria-label="发送设计指令" disabled={!project}>
<PaperPlaneTiltIcon size={18} weight="fill" />
</button>
</div>
@@ -311,7 +455,7 @@ export function WorkflowStudio() {
<div className="style-section">
<label htmlFor="style-concept"></label>
<textarea id="style-concept" defaultValue="温暖、克制、带自然材质感的现代住宅" />
<textarea key={project?.project_id ?? "empty"} id="style-concept" defaultValue={project?.style.concept ?? "导入图纸后建立 Style DNA"} disabled={!project} />
</div>
<div className="style-section">