feat: add real PDF ingestion workflow
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user