diff --git a/README.md b/README.md index 6c0d198..ac1e066 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,15 @@ docs 架构与工作流设计 ## 当前垂直切片 -- 显示样例户型与图层开关。 +- 从网页真实上传 25 MB 以内的 PDF,并把原文件与首页预览保存到 MinIO。 +- 判断矢量 PDF / 扫描 PDF,统计矢量元素,并检测同页中的多个候选平面区域。 +- 在图纸上直接选择目标户型,选择结果与工作流修订号持久化到 PostgreSQL。 - 展示从导入、清洗、白模到风格方向和多轮修改的阶段状态。 - 提供结构化 Style DNA 面板。 - 提供可验证的工作流状态机和命令接口。 - 预留 MinIO、百度 OCR、GPU Worker 和多模型路由适配器。 - 提供分类设置中心、可扩展模型池、逐模型测试、模型路由和工作流就绪门禁。 +当前图纸解析先处理 PDF 首页并完成“导入图纸 → 选择户型 → 进入结构确认”的真实闭环。墙、门窗、尺寸比例和房间多边形的语义分离属于下一阶段。 + 详见 [架构设计](docs/architecture.md)、[工作流定义](docs/workflow.md) 与 [系统设置](docs/settings.md)。 diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 44cf21a..618404c 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -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%; diff --git a/apps/web/components/workflow-studio.tsx b/apps/web/components/workflow-studio.tsx index f9a47ad..f66a434 100644 --- a/apps/web/components/workflow-studio.tsx +++ b/apps/web/components/workflow-studio.tsx @@ -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> = [ + { 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(null); + const [layers, setLayers] = useState([]); const [selectedDirection, setSelectedDirection] = useState("quiet-modern"); - const [messages, setMessages] = useState(initialMessages); + const [messages, setMessages] = useState([]); const [draft, setDraft] = useState(""); const [furnitureMode, setFurnitureMode] = useState<"reference" | "remove">("reference"); const [settingsOpen, setSettingsOpen] = useState(false); const [readiness, setReadiness] = useState(null); + const [uploading, setUploading] = useState(false); + const [workflowError, setWorkflowError] = useState(null); + const fileInputRef = useRef(null); const currentDirection = useMemo( () => styleDirections.find((item) => item.id === selectedDirection) ?? styleDirections[0], [selectedDirection], ); + const workflowStages = useMemo(() => { + 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 : []) + .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) { + 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() {

空间风格工作台

-

11-2-104 住宅概念方案

+

{project?.name ?? "新建住宅概念方案"}

@@ -116,12 +221,13 @@ export function WorkflowStudio() { + @@ -129,7 +235,7 @@ export function WorkflowStudio() { @@ -180,80 +290,107 @@ export function WorkflowStudio() { 适应画布 - -
-
-
- 上方住宅户型 - 矢量 PDF · 43 个 CAD 图层 -
-
- 从真实 PDF 中分离出的住宅建筑结构平面图 - -
- 已分离建筑结构 - 比例待交叉校准 -
+ {!project ? ( +
+
+

{uploading ? "正在读取和分析图纸" : "导入第一份真实户型图"}

+

当前支持 25 MB 以内的 PDF。系统会保留原文件、判断矢量或扫描类型,并检测同页中的多个候选平面区域。

+ + {workflowError ?

{workflowError}

: null}
- - -
+ +
+ )} @@ -271,6 +408,12 @@ export function WorkflowStudio() {
+ {!project ? ( +
+ +
等待图纸

导入 PDF 后,我会先判断图纸类型并请你选择目标户型区域。

+
+ ) : null} {messages.slice(-3).map((message) => (
{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} /> -
@@ -311,7 +455,7 @@ export function WorkflowStudio() {
-