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
+151
View File
@@ -434,6 +434,7 @@ textarea:focus-visible,
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 12px;
overflow: auto;
}
.canvas-labels,
@@ -465,6 +466,156 @@ textarea:focus-visible,
filter: contrast(1.02) saturate(0.72);
}
.plan-upload-empty {
min-height: 486px;
padding: 40px;
display: grid;
place-content: center;
justify-items: center;
gap: 14px;
text-align: center;
background:
radial-gradient(circle at 50% 30%, color-mix(in srgb, var(--accent) 10%, transparent), transparent 38%),
var(--surface-muted);
}
.plan-upload-empty h2 {
font-size: 20px;
letter-spacing: -0.025em;
}
.plan-upload-empty > p {
max-width: 530px;
color: var(--text-soft);
font-size: 12px;
line-height: 1.8;
}
.upload-emblem {
width: 62px;
height: 62px;
display: grid;
place-items: center;
color: var(--accent-strong);
border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--line));
border-radius: 18px;
background: color-mix(in srgb, var(--accent) 9%, var(--surface-raised));
box-shadow: var(--shadow);
}
.plan-document-frame {
position: relative;
width: min(100%, 720px);
margin: 0 auto;
align-self: start;
overflow: hidden;
border: 1px solid var(--line-strong);
border-radius: var(--radius-surface);
background: #fff;
box-shadow: var(--shadow);
}
.plan-document-image {
display: block;
width: 100%;
height: auto;
filter: contrast(1.02) saturate(0.75);
}
.region-layer {
position: absolute;
inset: 0;
}
.detected-region {
position: absolute;
min-width: 0;
min-height: 0;
padding: 8px;
display: flex;
align-items: flex-start;
gap: 6px;
color: var(--accent-strong);
border: 2px solid color-mix(in srgb, var(--accent) 72%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--accent) 8%, transparent);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.48);
text-align: left;
}
.detected-region > span {
width: 22px;
height: 22px;
flex: 0 0 22px;
display: grid;
place-items: center;
color: white;
border-radius: 50%;
background: var(--accent-strong);
font-size: 10px;
font-weight: 750;
}
.detected-region strong,
.detected-region small {
padding: 4px 6px;
border-radius: 5px;
background: rgb(255 255 255 / 0.9);
font-size: 10px;
line-height: 1.2;
}
.detected-region small {
margin-left: auto;
color: var(--text-soft);
}
.detected-region:hover,
.detected-region.recommended {
background: color-mix(in srgb, var(--accent) 14%, transparent);
}
.detected-region.selected {
border-color: var(--accent-strong);
background: color-mix(in srgb, var(--accent) 20%, transparent);
}
.detected-region:disabled {
cursor: default;
opacity: 0.78;
}
.ingestion-facts {
margin-top: 12px;
display: grid;
gap: 8px;
color: var(--text-soft);
font-size: 10px;
}
.ingestion-facts span,
.workflow-error {
display: flex;
align-items: center;
gap: 6px;
}
.workflow-error {
color: var(--warning) !important;
font-size: 11px !important;
}
.visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
.region-outline {
position: absolute;
inset: 8% 10% 9% 9%;
+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">
+68
View File
@@ -19,8 +19,76 @@ export interface WorkflowStage {
export interface PlanLayer {
id: string;
label: string;
category?: string;
visible: boolean;
count?: number;
element_count?: number | null;
source_names?: string[];
}
export interface PlanRegion {
id: string;
name: string;
bounds: [number, number, number, number];
recommended: boolean;
confidence: number;
}
export interface PlanIssue {
id: string;
kind: string;
message: string;
confidence: number;
resolved: boolean;
}
export interface PlanState {
source_name: string;
source_kind: string;
source_object_key?: string | null;
preview_object_key?: string | null;
preview_width?: number | null;
preview_height?: number | null;
page_count: number;
vector_based: boolean;
vector_element_count: number;
cad_layer_count: number;
ocr_used: boolean;
ocr_labels: string[];
ingestion_notes: string[];
regions: PlanRegion[];
selected_region_id?: string | null;
layers: PlanLayer[];
issues: PlanIssue[];
scale_mm_per_unit?: number | null;
ceiling_height_mm: number;
}
export interface ProjectSnapshot {
project_id: string;
name: string;
stage: WorkflowStageId | "failed";
revision: number;
plan: PlanState;
scene: {
blockout_asset_key?: string | null;
cameras: Array<{ id: string; label: string; room: string; status: string }>;
locked_object_ids: string[];
furniture_mode: string;
};
style: {
concept: string;
keywords: string[];
palette: Array<{ name: string; hex: string; role: string }>;
materials: string[];
lighting: string;
forms: string;
density: string;
avoid: string[];
locked_decisions: string[];
};
available_commands: string[];
updated_at: string;
}
export interface StyleDirection {