From a96b1bf9f9a48c45a38ddaa82cee275614670fef Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 2 Aug 2026 08:57:10 +0800 Subject: [PATCH] feat: ship end-to-end interior design MVP --- README.md | 13 +- apps/web/app/globals.css | 1088 ++++++++++++++++++ apps/web/components/workflow-studio.tsx | 901 ++++++++------- apps/web/lib/demo.ts | 25 - apps/web/types/workflow.ts | 58 +- contracts/workflow-contract.md | 15 + services/api/app/api/routes.py | 338 +++++- services/api/app/domain/models.py | 73 ++ services/api/app/domain/workflow.py | 41 + services/api/app/integrations/storage.py | 12 + services/api/app/services/design_pipeline.py | 352 ++++++ services/api/tests/test_design_pipeline.py | 66 ++ 12 files changed, 2497 insertions(+), 485 deletions(-) create mode 100644 services/api/app/services/design_pipeline.py create mode 100644 services/api/tests/test_design_pipeline.py diff --git a/README.md b/README.md index ac1e066..428362d 100644 --- a/README.md +++ b/README.md @@ -33,17 +33,22 @@ docs 架构与工作流设计 前端默认访问 `http://localhost:3000`,API 文档默认位于 `http://localhost:8000/docs`。 -## 当前垂直切片 +## 当前可体验产品 - 从网页真实上传 25 MB 以内的 PDF,并把原文件与首页预览保存到 MinIO。 - 判断矢量 PDF / 扫描 PDF,统计矢量元素,并检测同页中的多个候选平面区域。 - 在图纸上直接选择目标户型,选择结果与工作流修订号持久化到 PostgreSQL。 -- 展示从导入、清洗、白模到风格方向和多轮修改的阶段状态。 -- 提供结构化 Style DNA 面板。 +- 调用空间理解模型分析目标户型,输出房间、动线、采光、布局机会和风险;失败时自动建立可编辑草案,不阻塞流程。 +- 通过居住者、生活方式、重点空间、风格、颜色、禁用项和保留项完成需求访谈。 +- 调用总调度模型生成三套结构化 Style DNA,并支持人工选择设计方向。 +- 自动路由模式由总调度模型从候选池选择空间模型或生图模型;手动模式严格使用用户指定模型。 +- 调用真实生图模型生成关键空间效果图,结果保存到 MinIO 独立渲染桶。 +- 基于任意历史版本继续文字修改,保存父子版本关系,并可导出完整项目记录。 +- 右侧设计 Agent 支持多轮对话、追问和需求记录。 - 提供可验证的工作流状态机和命令接口。 - 预留 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 618404c..2742ed1 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -2125,3 +2125,1091 @@ textarea:focus-visible, transition-duration: 0.01ms !important; } } + +/* Product workspace */ +.product-shell { + min-height: 100dvh; + color: var(--text); + background: var(--bg); +} + +.product-topbar { + height: 66px; + padding: 0 18px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--surface-raised) 94%, transparent); +} + +.product-brand, +.product-top-actions, +.product-button, +.agent-heading, +.busy-banner, +.error-banner, +.render-meta, +.generation-note { + display: flex; + align-items: center; +} + +.product-brand { + gap: 10px; +} + +.product-brand > span, +.agent-heading > span { + width: 34px; + height: 34px; + display: grid; + place-items: center; + border-radius: 9px; + color: #f2f5f2; + background: var(--accent-strong); +} + +.product-brand div, +.agent-heading div { + display: grid; + gap: 2px; +} + +.product-brand strong { + font-size: 13px; + letter-spacing: 0.02em; +} + +.product-brand small, +.agent-heading small { + color: var(--text-soft); + font-size: 9px; +} + +.product-top-actions { + gap: 8px; +} + +.product-button { + min-height: 36px; + padding: 0 13px; + justify-content: center; + gap: 7px; + border: 1px solid transparent; + border-radius: 9px; + font-size: 10px; + font-weight: 660; + white-space: nowrap; + transition: transform 140ms ease, border-color 140ms ease, background 140ms ease; +} + +.product-button:active:not(:disabled) { + transform: translateY(1px); +} + +.product-button.primary { + color: #f5f8f6; + background: var(--accent-strong); +} + +.product-button.primary:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent-strong) 88%, #101713); +} + +.product-button.secondary { + border-color: var(--line); + background: var(--surface-raised); +} + +.product-button.ghost { + color: var(--text-soft); + background: transparent; +} + +.product-button.wide { + width: 100%; +} + +.product-body { + min-height: calc(100dvh - 66px); + display: grid; + grid-template-columns: 178px minmax(0, 1fr) 292px; +} + +.product-rail { + min-width: 0; + padding: 18px 12px; + display: flex; + flex-direction: column; + border-right: 1px solid var(--line); + background: var(--surface); +} + +.rail-progress { + padding: 0 8px 15px; + color: var(--text-faint); +} + +.rail-progress strong { + color: var(--text); + font-size: 22px; + font-weight: 620; +} + +.rail-progress span { + font-size: 10px; +} + +.product-rail nav { + display: grid; + gap: 3px; +} + +.product-rail nav > div { + min-height: 51px; + padding: 7px; + display: grid; + grid-template-columns: 25px 1fr; + align-items: center; + gap: 8px; + border-radius: 9px; + color: var(--text-faint); +} + +.product-rail nav > div > span { + width: 23px; + height: 23px; + display: grid; + place-items: center; + border: 1px solid var(--line); + border-radius: 7px; + font-size: 9px; +} + +.product-rail nav p { + margin: 0; + display: grid; + gap: 2px; +} + +.product-rail nav strong { + font-size: 10px; +} + +.product-rail nav small { + font-size: 8px; +} + +.product-rail nav > div.complete { + color: var(--text-soft); +} + +.product-rail nav > div.complete > span { + color: var(--accent-strong); + border-color: var(--accent-soft); + background: var(--accent-soft); +} + +.product-rail nav > div.active { + color: var(--text); + background: var(--surface-raised); + box-shadow: inset 0 0 0 1px var(--line); +} + +.product-rail nav > div.active > span { + color: #f5f8f6; + border-color: var(--accent-strong); + background: var(--accent-strong); +} + +.change-plan { + min-height: 38px; + margin-top: auto; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + border: 1px solid var(--line); + border-radius: 9px; + color: var(--text-soft); + background: transparent; + font-size: 9px; +} + +.product-center { + min-width: 0; + height: calc(100dvh - 66px); + overflow: auto; + position: relative; + background: + radial-gradient(circle at 86% 0%, color-mix(in srgb, var(--accent-soft) 62%, transparent), transparent 30%), + var(--bg); +} + +.busy-banner, +.error-banner { + position: sticky; + top: 0; + z-index: 4; + min-height: 42px; + padding: 0 18px; + gap: 8px; + border-bottom: 1px solid var(--line); + font-size: 10px; + backdrop-filter: blur(12px); +} + +.busy-banner { + color: var(--accent-strong); + background: color-mix(in srgb, var(--accent-soft) 90%, transparent); +} + +.error-banner { + color: var(--warning); + background: color-mix(in srgb, var(--warning-soft) 92%, transparent); +} + +.error-banner button { + margin-left: auto; + border: 0; + color: inherit; + background: transparent; + font-size: 9px; +} + +.product-stage { + width: min(1180px, 100%); + min-height: 100%; + margin: 0 auto; + padding: 28px 30px 44px; +} + +.stage-header { + margin-bottom: 22px; + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; +} + +.stage-header p, +.stage-header h1 { + margin: 0; +} + +.stage-header p, +.product-kicker { + margin-bottom: 5px; + color: var(--accent-strong); + font-size: 9px; + font-weight: 720; + letter-spacing: 0.08em; +} + +.stage-header h1 { + font-size: clamp(21px, 2.1vw, 31px); + font-weight: 630; + letter-spacing: -0.035em; +} + +.stage-header > span { + max-width: 230px; + color: var(--text-soft); + font-size: 9px; + text-align: right; +} + +.product-empty { + min-height: 70vh; + padding: 40px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--text-soft); + text-align: center; +} + +.product-empty h1, +.product-empty h2, +.product-empty p { + margin: 0; +} + +.product-onboarding h1 { + max-width: 700px; + color: var(--text); + font-size: clamp(30px, 4vw, 54px); + font-weight: 610; + line-height: 1.08; + letter-spacing: -0.05em; +} + +.product-onboarding > p:not(.product-kicker) { + max-width: 520px; + margin: 15px 0 22px; + font-size: 12px; + line-height: 1.7; +} + +.onboarding-art { + width: 110px; + height: 110px; + margin-bottom: 25px; + display: grid; + place-items: center; + border: 1px solid var(--line); + border-radius: 16px; + color: var(--accent-strong); + background: var(--surface-raised); + box-shadow: var(--shadow); +} + +.region-stage, +.structure-layout, +.render-setup-grid, +.render-workspace { + display: grid; + gap: 18px; +} + +.region-stage { + grid-template-columns: minmax(0, 1fr) 210px; +} + +.region-stage > aside, +.structure-review, +.render-controls, +.selected-direction-panel, +.render-sidebar { + padding: 18px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface-raised); +} + +.region-stage aside h2, +.region-stage aside p { + margin: 0; +} + +.region-stage aside h2 { + margin-bottom: 17px; + font-size: 11px; +} + +.region-stage aside > strong { + display: block; + margin-bottom: 8px; + font-size: 22px; +} + +.region-stage aside p { + margin-top: 12px; + color: var(--text-soft); + font-size: 9px; + line-height: 1.65; +} + +.product-plan-frame { + position: relative; + min-height: 420px; + overflow: hidden; + display: grid; + place-items: center; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--canvas); +} + +.product-plan-frame > img { + width: 100%; + height: auto; + max-height: 68vh; + display: block; + object-fit: contain; +} + +.product-region-layer { + position: absolute; + inset: 0; +} + +.product-region-layer button { + position: absolute; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + gap: 3px; + padding: 9px; + border: 2px solid color-mix(in srgb, var(--accent) 72%, transparent); + border-radius: 7px; + color: var(--accent-strong); + background: color-mix(in srgb, var(--accent-soft) 18%, transparent); +} + +.product-region-layer button:hover, +.product-region-layer button.recommended { + border-color: var(--accent-strong); + background: color-mix(in srgb, var(--accent-soft) 32%, transparent); +} + +.product-region-layer strong, +.product-region-layer span { + padding: 3px 5px; + border-radius: 5px; + background: color-mix(in srgb, var(--surface-raised) 92%, transparent); + font-size: 8px; +} + +.structure-layout { + grid-template-columns: minmax(380px, 1.1fr) minmax(330px, 0.9fr); + align-items: start; +} + +.structure-review { + display: grid; + gap: 15px; +} + +.analysis-callout { + min-height: 390px; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; +} + +.analysis-callout > svg { + margin-bottom: 18px; + color: var(--accent-strong); +} + +.analysis-callout h2, +.analysis-callout p { + margin: 0; +} + +.analysis-callout h2 { + font-size: 18px; +} + +.analysis-callout p { + margin: 8px 0 19px; + color: var(--text-soft); + font-size: 10px; + line-height: 1.65; +} + +.analysis-callout label { + width: 100%; + margin-bottom: 12px; + display: grid; + gap: 6px; + color: var(--text-soft); + font-size: 9px; +} + +.analysis-callout input, +.product-field input, +.product-field select, +.product-field textarea, +.edit-form textarea { + width: 100%; + border: 1px solid var(--line-strong); + border-radius: 9px; + color: var(--text); + background: var(--surface); +} + +.analysis-callout input, +.product-field input, +.product-field select { + height: 40px; + padding: 0 11px; +} + +.analysis-summary { + padding: 13px; + border-left: 3px solid var(--accent); + background: var(--accent-soft); +} + +.analysis-summary.warning { + border-left-color: var(--warning); + background: var(--warning-soft); +} + +.analysis-summary strong { + font-size: 11px; +} + +.analysis-summary p { + margin: 5px 0 0; + color: var(--text-soft); + font-size: 9px; + line-height: 1.55; +} + +.product-field { + display: grid; + gap: 6px; + color: var(--text-soft); + font-size: 9px; + font-weight: 640; +} + +.product-field small { + color: var(--text-faint); + font-size: 8px; + font-weight: 400; +} + +.product-field textarea { + min-height: 92px; + padding: 10px 11px; + resize: vertical; +} + +.two-fields, +.brief-grid, +.insight-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.insight-grid > div { + padding: 11px; + background: var(--surface-muted); +} + +.insight-grid h3 { + margin: 0 0 8px; + font-size: 9px; +} + +.insight-grid p { + margin: 6px 0 0; + display: flex; + align-items: flex-start; + gap: 5px; + color: var(--text-soft); + font-size: 8px; + line-height: 1.45; +} + +.insight-grid svg { + flex: 0 0 auto; + margin-top: 1px; +} + +.brief-form { + max-width: 820px; + display: grid; + gap: 20px; +} + +.wide-field { + max-width: 100%; +} + +.choice-group { + margin: 0; + padding: 0; + border: 0; +} + +.choice-group legend { + margin-bottom: 9px; + color: var(--text-soft); + font-size: 9px; + font-weight: 650; +} + +.choice-group > div { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.choice-group button { + min-height: 35px; + padding: 0 11px; + display: flex; + align-items: center; + gap: 5px; + border: 1px solid var(--line); + border-radius: 9px; + color: var(--text-soft); + background: var(--surface-raised); + font-size: 9px; +} + +.choice-group button.selected { + color: var(--accent-strong); + border-color: var(--accent); + background: var(--accent-soft); +} + +.form-submit { + width: fit-content; + min-width: 190px; +} + +.direction-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.direction-card { + min-width: 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface-raised); +} + +.palette-strip { + height: 82px; + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; +} + +.direction-body { + padding: 17px; +} + +.direction-body h2, +.direction-body p, +.direction-body dl { + margin: 0; +} + +.direction-body h2 { + font-size: 18px; + letter-spacing: -0.025em; +} + +.direction-body > p { + min-height: 72px; + margin-top: 8px; + color: var(--text-soft); + font-size: 9px; + line-height: 1.65; +} + +.keyword-row { + margin: 12px 0; + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.keyword-row span { + padding: 4px 6px; + border-radius: 6px; + color: var(--accent-strong); + background: var(--accent-soft); + font-size: 8px; +} + +.direction-body dl { + min-height: 110px; + display: grid; + grid-template-columns: 38px 1fr; + align-content: start; + gap: 7px; + font-size: 8px; + line-height: 1.5; +} + +.direction-body dt { + color: var(--text-faint); +} + +.direction-body dd { + margin: 0; + color: var(--text-soft); +} + +.direction-body > small { + min-height: 30px; + display: block; + color: var(--text-faint); + font-size: 7px; +} + +.render-setup-grid { + grid-template-columns: minmax(320px, 1fr) minmax(300px, 0.75fr); + align-items: stretch; +} + +.selected-direction-panel { + overflow: hidden; + padding: 0; +} + +.selected-direction-panel .palette-strip { + height: 150px; +} + +.selected-direction-panel h2, +.selected-direction-panel p, +.selected-direction-panel > strong { + margin-left: 20px; + margin-right: 20px; +} + +.selected-direction-panel h2 { + margin-top: 18px; + margin-bottom: 0; + font-size: 23px; +} + +.selected-direction-panel p { + color: var(--text-soft); + font-size: 10px; + line-height: 1.65; +} + +.selected-direction-panel > strong { + display: block; + margin-bottom: 20px; + color: var(--text-soft); + font-size: 9px; +} + +.render-controls { + display: grid; + align-content: start; + gap: 15px; +} + +.generation-note { + padding: 11px; + align-items: flex-start; + gap: 8px; + color: var(--text-soft); + background: var(--surface-muted); +} + +.generation-note svg { + flex: 0 0 auto; + color: var(--accent-strong); +} + +.generation-note p { + margin: 0; + font-size: 8px; + line-height: 1.6; +} + +.render-workspace { + grid-template-columns: minmax(0, 1fr) 255px; + align-items: start; +} + +.render-main { + overflow: hidden; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--canvas); +} + +.render-main > img { + width: 100%; + max-height: 68vh; + display: block; + object-fit: contain; +} + +.render-meta { + min-height: 38px; + padding: 0 11px; + justify-content: space-between; + color: var(--text-faint); + background: var(--surface-raised); + font-size: 8px; +} + +.render-sidebar h2 { + margin: 0 0 10px; + font-size: 11px; +} + +.version-list { + max-height: 230px; + overflow-y: auto; + display: grid; + gap: 5px; +} + +.version-list button { + min-height: 47px; + padding: 7px; + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 8px; + border: 1px solid transparent; + border-radius: 8px; + color: var(--text-soft); + background: var(--surface-muted); + text-align: left; +} + +.version-list button.active { + color: var(--text); + border-color: var(--accent); + background: var(--accent-soft); +} + +.version-list button span { + min-width: 0; + display: grid; + gap: 2px; +} + +.version-list strong, +.version-list small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.version-list strong { + font-size: 9px; +} + +.version-list small { + font-size: 7px; +} + +.edit-form { + margin-top: 15px; + padding-top: 14px; + display: grid; + gap: 8px; + border-top: 1px solid var(--line); +} + +.edit-form label { + display: grid; + gap: 7px; + color: var(--text-soft); + font-size: 9px; + font-weight: 650; +} + +.edit-form textarea { + min-height: 100px; + padding: 9px; + resize: vertical; + font-size: 9px; + line-height: 1.55; +} + +.agent-panel { + min-width: 0; + height: calc(100dvh - 66px); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + border-left: 1px solid var(--line); + background: var(--surface); +} + +.agent-heading { + min-height: 62px; + padding: 11px 13px; + gap: 9px; + border-bottom: 1px solid var(--line); +} + +.agent-heading > span { + width: 31px; + height: 31px; + color: var(--accent-strong); + background: var(--accent-soft); +} + +.agent-heading strong { + font-size: 10px; +} + +.agent-stream { + min-height: 0; + overflow-y: auto; + padding: 13px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.agent-message { + max-width: 92%; + padding: 9px 10px; + align-self: flex-start; + border-radius: 10px 10px 10px 3px; + color: var(--text-soft); + background: var(--surface-raised); + box-shadow: inset 0 0 0 1px var(--line); +} + +.agent-message.user { + align-self: flex-end; + border-radius: 10px 10px 3px 10px; + color: var(--accent-strong); + background: var(--accent-soft); + box-shadow: none; +} + +.agent-message small, +.agent-message p { + margin: 0; +} + +.agent-message small { + display: block; + margin-bottom: 3px; + color: var(--text-faint); + font-size: 7px; +} + +.agent-message p { + font-size: 9px; + line-height: 1.55; +} + +.agent-input { + padding: 10px; + display: grid; + grid-template-columns: 1fr 32px; + align-items: end; + gap: 6px; + border-top: 1px solid var(--line); +} + +.agent-input textarea { + min-height: 60px; + max-height: 130px; + padding: 9px; + resize: none; + border: 1px solid var(--line-strong); + border-radius: 9px; + color: var(--text); + background: var(--surface-raised); + font-size: 9px; + line-height: 1.5; +} + +.agent-input button { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border: 0; + border-radius: 8px; + color: #f5f8f6; + background: var(--accent-strong); +} + +@media (max-width: 1120px) { + .product-body { + grid-template-columns: 156px minmax(0, 1fr); + } + + .agent-panel { + display: none; + } +} + +@media (max-width: 760px) { + .product-topbar { + padding: 0 12px; + } + + .product-button.ghost, + .product-brand small { + display: none; + } + + .product-body { + display: block; + } + + .product-rail { + padding: 7px 10px; + overflow-x: auto; + flex-direction: row; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .rail-progress, + .change-plan, + .product-rail nav small { + display: none; + } + + .product-rail nav { + display: flex; + } + + .product-rail nav > div { + min-width: 88px; + min-height: 38px; + grid-template-columns: 20px 1fr; + } + + .product-rail nav > div > span { + width: 20px; + height: 20px; + } + + .product-center { + height: auto; + min-height: calc(100dvh - 120px); + } + + .product-stage { + padding: 22px 14px 38px; + } + + .stage-header { + align-items: flex-start; + flex-direction: column; + } + + .stage-header > span { + text-align: left; + } + + .region-stage, + .structure-layout, + .render-setup-grid, + .render-workspace, + .direction-grid, + .brief-grid, + .two-fields, + .insight-grid { + grid-template-columns: 1fr; + } + + .product-plan-frame { + min-height: 300px; + } + + .direction-body > p, + .direction-body dl { + min-height: auto; + } + + .render-main > img { + max-height: none; + } +} diff --git a/apps/web/components/workflow-studio.tsx b/apps/web/components/workflow-studio.tsx index f66a434..255ff2c 100644 --- a/apps/web/components/workflow-studio.tsx +++ b/apps/web/components/workflow-studio.tsx @@ -1,22 +1,18 @@ "use client"; -import * as Tabs from "@radix-ui/react-tabs"; -import * as Tooltip from "@radix-ui/react-tooltip"; import { ArrowRightIcon, - ArrowsOutIcon, BuildingsIcon, CheckIcon, CircleNotchIcon, CubeIcon, - EyeIcon, - EyeSlashIcon, + DownloadSimpleIcon, GearSixIcon, ImageIcon, - LockSimpleIcon, + MagicWandIcon, PaperPlaneTiltIcon, + PencilSimpleIcon, PlusIcon, - SlidersHorizontalIcon, SparkleIcon, UploadSimpleIcon, WarningCircleIcon, @@ -24,493 +20,492 @@ import { import { ChangeEvent, FormEvent, useEffect, useMemo, useRef, useState } from "react"; import { SettingsCenter } from "@/components/settings-center"; -import { styleDirections } from "@/lib/demo"; -import type { ChatMessage, PlanLayer, ProjectSnapshot, WorkflowStage } from "@/types/workflow"; +import type { DesignBrief, ProjectSnapshot, RenderAsset, WorkflowStageId } 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: "局部编辑" }, + +const EMPTY_BRIEF: DesignBrief = { + residents: "", + lifestyle: [], + focus_rooms: [], + preferred_styles: [], + preferred_colors: [], + disliked_elements: [], + must_keep: [], + budget_level: "适中", + additional_notes: "", + completed: false, +}; + +const STAGES: Array<{ ids: Array; label: string; detail: string }> = [ + { ids: ["uploaded", "region_selection"], label: "导入图纸", detail: "找到正确户型" }, + { ids: ["plan_review", "blockout"], label: "理解空间", detail: "房间、动线、采光" }, + { ids: ["style_brief"], label: "说清需求", detail: "生活方式与偏好" }, + { ids: ["direction_selection"], label: "选择方向", detail: "三套 Style DNA" }, + { ids: ["render_review"], label: "生成效果", detail: "真实模型出图" }, + { ids: ["editing"], label: "多轮修改", detail: "保留版本继续改" }, + { ids: ["completed"], label: "完成方案", detail: "查看与导出" }, ]; +const LIFESTYLE_OPTIONS = ["经常下厨", "在家办公", "亲子活动", "朋友聚会", "重视收纳", "养宠物"]; +const STYLE_OPTIONS = ["现代简约", "原木自然", "中古现代", "侘寂", "现代法式", "轻工业"]; +const COLOR_OPTIONS = ["黑白灰", "自然木色", "低饱和彩色", "冷色调", "暖色调", "深色氛围"]; + +function stageIndex(stage?: WorkflowStageId | "failed") { + if (!stage) return 0; + const index = STAGES.findIndex((item) => item.ids.includes(stage)); + return index < 0 ? 0 : index; +} + +function PlanPreview({ project, selectable, onSelect }: { + project: ProjectSnapshot; + selectable?: boolean; + onSelect?: (id: string) => void; +}) { + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element -- local authenticated project preview. */} + {`${project.name} + {selectable ? ( +
+ {project.plan.regions.map((region, index) => { + const [x0, y0, x1, y1] = region.bounds; + return ( + + ); + })} +
+ ) : null} +
+ ); +} + +function ChoiceGroup({ label, options, values, onChange }: { + label: string; + options: string[]; + values: string[]; + onChange: (next: string[]) => void; +}) { + return ( +
+ {label} +
+ {options.map((option) => ( + + ))} +
+
+ ); +} + export function WorkflowStudio() { const [project, setProject] = useState(null); - const [layers, setLayers] = useState([]); - const [selectedDirection, setSelectedDirection] = useState("quiet-modern"); - 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 [settingsOpen, setSettingsOpen] = useState(false); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(""); + const [error, setError] = useState(""); + const [brief, setBrief] = useState(EMPTY_BRIEF); + const [grossArea, setGrossArea] = useState(""); + const [ceilingHeight, setCeilingHeight] = useState("2800"); + const [roomNames, setRoomNames] = useState(""); + const [renderRoom, setRenderRoom] = useState("客餐厅"); + const [renderView, setRenderView] = useState("入口看向客厅"); + const [activeRenderId, setActiveRenderId] = useState(""); + const [editInstruction, setEditInstruction] = useState(""); + const [chatDraft, setChatDraft] = useState(""); + const [chatReply, setChatReply] = useState(""); + const fileInput = 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, + const activeStage = stageIndex(project?.stage); + const selectedDirection = useMemo( + () => project?.directions.find((item) => item.id === project.style.selected_direction_id) ?? null, [project], ); + const activeRender = useMemo(() => { + if (!project?.renders.length) return null; + return project.renders.find((item) => item.id === activeRenderId) ?? project.renders.at(-1) ?? null; + }, [activeRenderId, 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: "等待确认", - })), - ]); + setBrief(next.brief ?? EMPTY_BRIEF); + setGrossArea(next.plan.gross_area_sqm ? String(next.plan.gross_area_sqm) : ""); + setCeilingHeight(String(next.plan.ceiling_height_mm || 2800)); + setRoomNames(next.plan.structure?.rooms?.map((room) => room.name).join("、") ?? ""); + if (next.plan.structure?.rooms?.[0]?.name) setRenderRoom(next.plan.structure.rooms[0].name); + if (next.renders.length) setActiveRenderId(next.renders[next.renders.length - 1].id); } useEffect(() => { - async function loadReadiness() { + async function boot() { try { - const response = await fetch(`${API_BASE}/v1/readiness`, { cache: "no-store" }); - if (!response.ok) return; - const current = await response.json() as SettingsReadiness; - setReadiness(current); - if (!current.ready && !window.sessionStorage.getItem("settings-intro-seen")) { - window.sessionStorage.setItem("settings-intro-seen", "true"); - setSettingsOpen(true); + const [readinessResponse, projectsResponse] = await Promise.all([ + fetch(`${API_BASE}/v1/readiness`, { cache: "no-store" }), + fetch(`${API_BASE}/v1/projects`, { cache: "no-store" }), + ]); + if (readinessResponse.ok) { + const current = await readinessResponse.json() as SettingsReadiness; + setReadiness(current); + if (!current.ready) setSettingsOpen(true); + } + if (projectsResponse.ok) { + const projects = await projectsResponse.json() as ProjectSnapshot[]; + const latest = projects.find((item) => item.project_id !== "demo-apartment"); + if (latest) applyProject(latest); } } catch { - setReadiness(null); + setError("无法连接本地服务,请检查 Docker 是否正在运行。"); + } finally { + setLoading(false); } } - 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); + void boot(); }, []); - async function readApiError(response: Response, fallback: string) { + async function apiError(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. - } + const body = await response.json() as { detail?: string | { message?: string } }; + if (typeof body.detail === "string") return body.detail; + if (body.detail?.message) return body.detail.message; + } catch { /* use 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`, { + async function command(base: ProjectSnapshot, name: string, payload: Record = {}) { + const response = await fetch(`${API_BASE}/v1/projects/${base.project_id}/commands`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - command: "select_region", - expected_revision: project.revision, - payload: { region_id: regionId }, - }), + body: JSON.stringify({ command: name, expected_revision: base.revision, payload }), }); - if (!response.ok) { - setWorkflowError(await readApiError(response, "候选户型选择失败。")); + if (!response.ok) throw new Error(await apiError(response, "工作流操作失败。")); + return response.json() as Promise; + } + + async function upload(file: File) { + if (!readiness?.ready) { + setSettingsOpen(true); return; } - applyProject(await response.json() as ProjectSnapshot); + setBusy("正在读取 PDF 并寻找户型区域"); + setError(""); + 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 apiError(response, "图纸上传失败。")); + applyProject(await response.json() as ProjectSnapshot); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "图纸上传失败。"); + } finally { + setBusy(""); + if (fileInput.current) fileInput.current.value = ""; + } } - function toggleLayer(id: string) { - setLayers((items) => - items.map((item) => (item.id === id ? { ...item, visible: !item.visible } : item)), - ); + function onFile(event: ChangeEvent) { + const file = event.target.files?.[0]; + if (file) void upload(file); } - function sendMessage(event: FormEvent) { + async function chooseRegion(id: string) { + if (!project) return; + setBusy("正在建立目标户型"); + setError(""); + try { applyProject(await command(project, "select_region", { region_id: id })); } + catch (reason) { setError(reason instanceof Error ? reason.message : "选择失败。"); } + finally { setBusy(""); } + } + + async function analyzePlan() { + if (!project) return; + setBusy("空间模型正在识别房间、动线与采光"); + setError(""); + try { + const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/spatial-analysis`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ gross_area_sqm: grossArea ? Number(grossArea) : null }), + }); + if (!response.ok) throw new Error(await apiError(response, "空间分析失败。")); + applyProject(await response.json() as ProjectSnapshot); + } catch (reason) { setError(reason instanceof Error ? reason.message : "空间分析失败。"); } + finally { setBusy(""); } + } + + async function confirmStructure() { + if (!project) return; + setBusy("正在建立空间骨架和推荐视角"); + setError(""); + try { + const confirmed = project.stage === "blockout" + ? project + : await command(project, "confirm_plan", { + gross_area_sqm: grossArea ? Number(grossArea) : null, + ceiling_height_mm: Number(ceilingHeight) || 2800, + room_names: roomNames.split(/[、,,\n]/).map((item) => item.trim()).filter(Boolean), + }); + applyProject(await command(confirmed, "build_blockout")); + } catch (reason) { setError(reason instanceof Error ? reason.message : "结构确认失败。"); } + finally { setBusy(""); } + } + + async function createDirections() { + if (!project) return; + setBusy("设计总监正在生成三套 Style DNA"); + setError(""); + try { + const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/style-directions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ brief }), + }); + if (!response.ok) throw new Error(await apiError(response, "风格方向生成失败。")); + applyProject(await response.json() as ProjectSnapshot); + } catch (reason) { setError(reason instanceof Error ? reason.message : "风格方向生成失败。"); } + finally { setBusy(""); } + } + + async function selectDirection(id: string) { + if (!project) return; + setBusy("正在锁定设计方向"); + setError(""); + try { applyProject(await command(project, "select_direction", { direction_id: id })); } + catch (reason) { setError(reason instanceof Error ? reason.message : "方向选择失败。"); } + finally { setBusy(""); } + } + + async function generateRender() { + if (!project?.style.selected_direction_id) return; + setBusy("真实生图模型正在生成效果图,通常需要 20 秒到 2 分钟"); + setError(""); + try { + const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/renders`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ direction_id: project.style.selected_direction_id, room: renderRoom, view: renderView }), + }); + if (!response.ok) throw new Error(await apiError(response, "效果图生成失败。")); + applyProject(await response.json() as ProjectSnapshot); + } catch (reason) { setError(reason instanceof Error ? reason.message : "效果图生成失败。"); } + finally { setBusy(""); } + } + + async function editRender(event: FormEvent) { event.preventDefault(); - const value = draft.trim(); - if (!value) return; - setMessages((items) => [ - ...items, - { id: `user-${Date.now()}`, role: "user", body: value }, - { - id: `assistant-${Date.now()}`, - role: "assistant", - body: "已记录为设计约束。正式接入后,这条指令会先更新布局锁定和 Style DNA,再只重绘受影响视角。", - meta: "结构化修改", - }, - ]); - setDraft(""); + if (!project || !activeRender || !editInstruction.trim()) return; + setBusy("正在只修改指定内容,并保持其他区域不变"); + setError(""); + try { + const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/renders/${activeRender.id}/edit`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ instruction: editInstruction.trim() }), + }); + if (!response.ok) throw new Error(await apiError(response, "效果图修改失败。")); + applyProject(await response.json() as ProjectSnapshot); + setEditInstruction(""); + } catch (reason) { setError(reason instanceof Error ? reason.message : "效果图修改失败。"); } + finally { setBusy(""); } + } + + async function chat(event: FormEvent) { + event.preventDefault(); + if (!project || !chatDraft.trim()) return; + const message = chatDraft.trim(); + setChatDraft(""); + setBusy("设计 Agent 正在理解你的要求"); + try { + const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message }), + }); + if (!response.ok) throw new Error(await apiError(response, "设计 Agent 暂时无法回复。")); + const result = await response.json() as { project: ProjectSnapshot; reply: string }; + applyProject(result.project); + setChatReply(result.reply); + } catch (reason) { setError(reason instanceof Error ? reason.message : "设计 Agent 暂时无法回复。"); } + finally { setBusy(""); } + } + + async function completeProject() { + if (!project) return; + setBusy("正在整理最终方案"); + try { applyProject(await command(project, "complete_project")); } + catch (reason) { setError(reason instanceof Error ? reason.message : "方案整理失败。"); } + finally { setBusy(""); } + } + + function downloadSummary() { + if (!project) return; + const payload = JSON.stringify(project, null, 2); + const link = document.createElement("a"); + link.href = URL.createObjectURL(new Blob([payload], { type: "application/json" })); + link.download = `${project.name}-方案记录.json`; + link.click(); + URL.revokeObjectURL(link.href); + } + + function renderWorkspace() { + if (loading) return

正在打开工作台

; + if (!project) return ( +
+
+

从真实户型开始

+

把一张平面图,变成可反复修改的风格方案

+

上传 PDF 后,系统会带你完成空间理解、风格选择、真实生图和多轮修改。

+ +
+ ); + + if (project.stage === "region_selection") return ( +
+

先选对户型

这页图纸里,哪一块是你要设计的家?

点击图中的候选框
+
void chooseRegion(id)} />
+
+ ); + + if (project.stage === "plan_review" || project.stage === "blockout") { + const structure = project.plan.structure; + return ( +
+

空间理解

先确认布局事实,再谈风格

{structure.status === "pending" ? "等待 AI 读图" : structure.degraded ? "可编辑草案" : `由 ${structure.model_name} 识别`}
+
+ +
+ {structure.status === "pending" ? ( +

让空间模型读懂这套户型

它会判断房间、主要动线、采光和布局机会。结果可以在下一步人工修正。

+ ) : ( + <> +
{structure.summary}

{structure.degraded ? "模型失败并不阻塞流程,请直接修改下面的房间名称。" : structure.circulation}

+ +
+

布局机会

{structure.layout_opportunities.map((item) =>

{item}

)}

需要留意

{structure.risks.map((item) =>

{item}

)}
+ + + )} +
+
+
+ ); + } + + if (project.stage === "style_brief") return ( +
+

风格需求

不需要懂专业术语,说清你怎么生活就够了

约 2 分钟
+
+ + setBrief({ ...brief, lifestyle })} /> + setBrief({ ...brief, preferred_styles })} /> + setBrief({ ...brief, preferred_colors })} /> +
+
+