"use client"; import { ArrowRightIcon, BuildingsIcon, CheckIcon, CircleNotchIcon, CubeIcon, DownloadSimpleIcon, GearSixIcon, ImageIcon, MagicWandIcon, PaperPlaneTiltIcon, PencilSimpleIcon, PlusIcon, SparkleIcon, UploadSimpleIcon, WarningCircleIcon, } from "@phosphor-icons/react"; import { ChangeEvent, FormEvent, useEffect, useMemo, useRef, useState } from "react"; import { SettingsCenter } from "@/components/settings-center"; import type { DesignBrief, ProjectSnapshot, RenderAsset, WorkflowStageId } from "@/types/workflow"; import type { SettingsReadiness } from "@/types/settings"; const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000"; 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 [readiness, setReadiness] = useState(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 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); 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 boot() { try { 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 { setError("无法连接本地服务,请检查 Docker 是否正在运行。"); } finally { setLoading(false); } } void boot(); }, []); async function apiError(response: Response, fallback: string) { try { 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 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: name, expected_revision: base.revision, payload }), }); 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; } 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 onFile(event: ChangeEvent) { const file = event.target.files?.[0]; if (file) void upload(file); } 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(); 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 })} />