feat: ship end-to-end interior design MVP

This commit is contained in:
Codex
2026-08-02 08:57:10 +08:00
parent 4d97a30616
commit a96b1bf9f9
12 changed files with 2497 additions and 485 deletions
File diff suppressed because it is too large Load Diff
+448 -453
View File
@@ -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<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: "局部编辑" },
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<WorkflowStageId | "failed">; 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 (
<div className="product-plan-frame">
{/* eslint-disable-next-line @next/next/no-img-element -- local authenticated project preview. */}
<img src={`${API_BASE}/v1/projects/${project.project_id}/preview`} alt={`${project.name} 户型图`} />
{selectable ? (
<div className="product-region-layer">
{project.plan.regions.map((region, index) => {
const [x0, y0, x1, y1] = region.bounds;
return (
<button
key={region.id}
type="button"
className={region.recommended ? "recommended" : ""}
style={{ left: `${x0 * 100}%`, top: `${y0 * 100}%`, width: `${(x1 - x0) * 100}%`, height: `${(y1 - y0) * 100}%` }}
onClick={() => onSelect?.(region.id)}
>
<strong> {index + 1}</strong>
<span>{Math.round(region.confidence * 100)}%{region.recommended ? ",建议" : ""}</span>
</button>
);
})}
</div>
) : null}
</div>
);
}
function ChoiceGroup({ label, options, values, onChange }: {
label: string;
options: string[];
values: string[];
onChange: (next: string[]) => void;
}) {
return (
<fieldset className="choice-group">
<legend>{label}</legend>
<div>
{options.map((option) => (
<button
key={option}
type="button"
className={values.includes(option) ? "selected" : ""}
onClick={() => onChange(values.includes(option) ? values.filter((item) => item !== option) : [...values, option])}
>
{values.includes(option) ? <CheckIcon size={13} weight="bold" /> : null}{option}
</button>
))}
</div>
</fieldset>
);
}
export function WorkflowStudio() {
const [project, setProject] = useState<ProjectSnapshot | null>(null);
const [layers, setLayers] = useState<PlanLayer[]>([]);
const [selectedDirection, setSelectedDirection] = useState("quiet-modern");
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 [settingsOpen, setSettingsOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
const [brief, setBrief] = useState<DesignBrief>(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<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,
const activeStage = stageIndex(project?.stage);
const selectedDirection = useMemo(
() => project?.directions.find((item) => item.id === project.style.selected_direction_id) ?? null,
[project],
);
const activeRender = useMemo<RenderAsset | null>(() => {
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<ProjectSnapshot[]> : [])
.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<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`, {
async function command(base: ProjectSnapshot, name: string, payload: Record<string, unknown> = {}) {
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<ProjectSnapshot>;
}
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<HTMLInputElement>) {
const file = event.target.files?.[0];
if (file) void upload(file);
}
function sendMessage(event: FormEvent<HTMLFormElement>) {
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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 <div className="product-empty"><CircleNotchIcon className="spin" size={28} /><h2></h2></div>;
if (!project) return (
<div className="product-empty product-onboarding">
<div className="onboarding-art"><BuildingsIcon size={58} weight="thin" /></div>
<p className="product-kicker"></p>
<h1></h1>
<p> PDF </p>
<button className="product-button primary" type="button" onClick={() => readiness?.ready ? fileInput.current?.click() : setSettingsOpen(true)}>
<UploadSimpleIcon size={17} />{readiness?.ready ? "选择 PDF 户型图" : "先完成系统设置"}
</button>
</div>
);
if (project.stage === "region_selection") return (
<section className="product-stage">
<header className="stage-header"><div><p></p><h1></h1></div><span></span></header>
<div className="region-stage"><PlanPreview project={project} selectable onSelect={(id) => void chooseRegion(id)} /><aside><h2></h2><strong>{project.plan.regions.length} </strong><p>{project.plan.vector_based ? `矢量 PDF,共读取 ${project.plan.vector_element_count.toLocaleString()} 个图形元素。` : "扫描型图纸,已使用视觉区域检测。"}</p><p> PDF</p></aside></div>
</section>
);
if (project.stage === "plan_review" || project.stage === "blockout") {
const structure = project.plan.structure;
return (
<section className="product-stage">
<header className="stage-header"><div><p></p><h1></h1></div><span>{structure.status === "pending" ? "等待 AI 读图" : structure.degraded ? "可编辑草案" : `${structure.model_name} 识别`}</span></header>
<div className="structure-layout">
<PlanPreview project={project} />
<div className="structure-review">
{structure.status === "pending" ? (
<div className="analysis-callout"><MagicWandIcon size={28} /><h2></h2><p>线</p><label><input value={grossArea} onChange={(event) => setGrossArea(event.target.value)} inputMode="decimal" placeholder="例如 108" /></label><button className="product-button primary" type="button" onClick={() => void (project.stage === "blockout" ? confirmStructure() : analyzePlan())}>{project.stage === "blockout" ? "继续进入风格需求" : "开始空间分析"}</button></div>
) : (
<>
<div className={`analysis-summary ${structure.degraded ? "warning" : ""}`}><strong>{structure.summary}</strong><p>{structure.degraded ? "模型失败并不阻塞流程,请直接修改下面的房间名称。" : structure.circulation}</p></div>
<label className="product-field"><input value={roomNames} onChange={(event) => setRoomNames(event.target.value)} placeholder="客厅、餐厅、主卧、次卧、厨房、卫生间" /><small> AI </small></label>
<div className="two-fields"><label className="product-field"><input value={grossArea} onChange={(event) => setGrossArea(event.target.value)} inputMode="decimal" /></label><label className="product-field">mm<input value={ceilingHeight} onChange={(event) => setCeilingHeight(event.target.value)} inputMode="numeric" /></label></div>
<div className="insight-grid"><div><h3></h3>{structure.layout_opportunities.map((item) => <p key={item}><CheckIcon size={13} />{item}</p>)}</div><div><h3></h3>{structure.risks.map((item) => <p key={item}><WarningCircleIcon size={13} />{item}</p>)}</div></div>
<button className="product-button primary wide" type="button" onClick={() => void confirmStructure()}> <ArrowRightIcon size={16} /></button>
</>
)}
</div>
</div>
</section>
);
}
if (project.stage === "style_brief") return (
<section className="product-stage brief-stage">
<header className="stage-header"><div><p></p><h1></h1></div><span> 2 </span></header>
<div className="brief-form">
<label className="product-field wide-field"><input value={brief.residents} onChange={(event) => setBrief({ ...brief, residents: event.target.value })} placeholder="例如:两位大人、一个 6 岁孩子和一只猫" /></label>
<ChoiceGroup label="平时最重要的生活场景" options={LIFESTYLE_OPTIONS} values={brief.lifestyle} onChange={(lifestyle) => setBrief({ ...brief, lifestyle })} />
<ChoiceGroup label="更接近你的风格" options={STYLE_OPTIONS} values={brief.preferred_styles} onChange={(preferred_styles) => setBrief({ ...brief, preferred_styles })} />
<ChoiceGroup label="喜欢的色彩倾向" options={COLOR_OPTIONS} values={brief.preferred_colors} onChange={(preferred_colors) => setBrief({ ...brief, preferred_colors })} />
<div className="brief-grid"><label className="product-field"><input value={brief.focus_rooms.join("、")} onChange={(event) => setBrief({ ...brief, focus_rooms: event.target.value.split(/[、,]/).filter(Boolean) })} placeholder="客餐厅、主卧" /></label><label className="product-field"><select value={brief.budget_level} onChange={(event) => setBrief({ ...brief, budget_level: event.target.value })}><option></option><option></option><option></option></select></label></div>
<div className="brief-grid"><label className="product-field"><input value={brief.disliked_elements.join("、")} onChange={(event) => setBrief({ ...brief, disliked_elements: event.target.value.split(/[、,]/).filter(Boolean) })} placeholder="例如:亮面石材、复杂吊顶" /></label><label className="product-field"><input value={brief.must_keep.join("、")} onChange={(event) => setBrief({ ...brief, must_keep: event.target.value.split(/[、,]/).filter(Boolean) })} placeholder="例如:旧餐桌、钢琴" /></label></div>
<label className="product-field wide-field"><textarea value={brief.additional_notes} onChange={(event) => setBrief({ ...brief, additional_notes: event.target.value })} placeholder="例如:希望客厅看起来更松弛,但不要像民宿" /></label>
<button className="product-button primary form-submit" type="button" onClick={() => void createDirections()}> <SparkleIcon size={16} weight="fill" /></button>
</div>
</section>
);
if (project.stage === "direction_selection") return (
<section className="product-stage directions-stage">
<header className="stage-header"><div><p></p><h1></h1></div><span></span></header>
<div className="direction-grid">
{project.directions.map((direction) => (
<article className="direction-card" key={direction.id}>
<div className="palette-strip">{direction.palette.map((color) => <span key={color.hex} style={{ background: color.hex }} title={`${color.name}${color.role}`} />)}</div>
<div className="direction-body"><h2>{direction.name}</h2><p>{direction.thesis}</p><div className="keyword-row">{direction.keywords.map((item) => <span key={item}>{item}</span>)}</div><dl><dt></dt><dd>{direction.materials.join("、")}</dd><dt>线</dt><dd>{direction.lighting}</dd></dl><small>{direction.model_name || "设计策略"}</small><button className="product-button primary wide" type="button" onClick={() => void selectDirection(direction.id)}></button></div>
</article>
))}
</div>
</section>
);
if (project.stage === "render_review") return (
<section className="product-stage render-setup">
<header className="stage-header"><div><p></p><h1></h1></div><span></span></header>
<div className="render-setup-grid">
<div className="selected-direction-panel"><div className="palette-strip">{selectedDirection?.palette.map((color) => <span key={color.hex} style={{ background: color.hex }} />)}</div><h2>{selectedDirection?.name}</h2><p>{selectedDirection?.thesis}</p><strong>{selectedDirection?.materials.join("、")}</strong></div>
<div className="render-controls"><label className="product-field"><select value={renderRoom} onChange={(event) => setRenderRoom(event.target.value)}>{project.plan.structure.rooms.map((room) => <option key={room.id}>{room.name}</option>)}<option></option><option></option></select></label><label className="product-field"><select value={renderView} onChange={(event) => setRenderView(event.target.value)}><option></option><option></option><option></option><option>广</option></select></label><div className="generation-note"><CubeIcon size={20} /><p> Style DNA </p></div><button className="product-button primary wide" type="button" onClick={() => void generateRender()}><ImageIcon size={17} /></button></div>
</div>
</section>
);
if (project.stage === "editing" || project.stage === "completed") return (
<section className="product-stage editing-stage">
<header className="stage-header"><div><p>{project.stage === "completed" ? "方案已完成" : "效果图与修改"}</p><h1>{activeRender?.room}{activeRender?.view}</h1></div><span>{project.renders.length} </span></header>
{activeRender ? (
<div className="render-workspace">
<div className="render-main">
{/* eslint-disable-next-line @next/next/no-img-element -- generated project asset. */}
<img src={`${API_BASE}/v1/projects/${project.project_id}/renders/${activeRender.id}`} alt={`${activeRender.room} ${activeRender.view} 效果图`} />
<div className="render-meta"><span>{activeRender.model_name}</span><span>{new Date(activeRender.created_at).toLocaleString("zh-CN")}</span></div>
</div>
<aside className="render-sidebar"><h2></h2><div className="version-list">{[...project.renders].reverse().map((render, index) => <button className={render.id === activeRender.id ? "active" : ""} type="button" key={render.id} onClick={() => setActiveRenderId(render.id)}><ImageIcon size={16} /><span><strong> {project.renders.length - index}</strong><small>{render.edit_instruction || `${render.room}${render.view}`}</small></span></button>)}</div>{project.stage === "editing" ? <form className="edit-form" onSubmit={editRender}><label><textarea value={editInstruction} onChange={(event) => setEditInstruction(event.target.value)} placeholder="例如:把沙发改为更低矮的米灰色模块沙发,其他都不变" /></label><button className="product-button primary wide" type="submit" disabled={!editInstruction.trim()}><PencilSimpleIcon size={16} /></button><button className="product-button secondary wide" type="button" onClick={() => void completeProject()}></button></form> : <button className="product-button primary wide" type="button" onClick={downloadSummary}><DownloadSimpleIcon size={16} /></button>}</aside>
</div>
) : <div className="product-empty"><ImageIcon size={36} /><h2></h2></div>}
</section>
);
return <div className="product-empty"><CubeIcon size={34} /><h2></h2></div>;
}
return (
<Tooltip.Provider delayDuration={250}>
<main className="studio-shell">
<header className="topbar">
<div className="brand-block">
<div className="brand-mark" aria-hidden="true">
<BuildingsIcon size={19} weight="fill" />
</div>
<div>
<p className="brand-name"></p>
<p className="brand-subtitle">{project?.name ?? "新建住宅概念方案"}</p>
</div>
</div>
<div className="topbar-actions">
<span className="save-state"><CheckIcon size={14} weight="bold" /> </span>
<button className="button button-secondary settings-button" type="button" onClick={() => setSettingsOpen(true)}>
<GearSixIcon size={16} />
{readiness && !readiness.ready ? <span className="settings-alert-dot" aria-label="有必备配置未完成" /> : null}
</button>
<button className="button button-secondary" type="button"></button>
<button
className="button button-primary"
type="button"
disabled={uploading || Boolean(project)}
onClick={() => readiness?.ready ? fileInputRef.current?.click() : setSettingsOpen(true)}
title={!readiness?.ready ? "请先完成系统设置" : project ? "当前阶段请在图纸上完成候选区域确认" : undefined}
>
{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>
<div className="studio-grid">
<aside className="workflow-rail" aria-label="设计流程">
<div className="rail-heading">
<p></p>
<span>{project ? Math.max(1, stageMeta.findIndex((item) => item.id === project.stage) + 1) : 0} / 8</span>
</div>
<nav className="stage-list">
{workflowStages.map((stage) => (
<button
className={`stage-item stage-${stage.status}`}
key={stage.id}
type="button"
aria-current={stage.status === "active" ? "step" : undefined}
>
<span className="stage-index" aria-hidden="true">
{stage.status === "complete" ? <CheckIcon size={13} weight="bold" /> : null}
</span>
<span className="stage-copy">
<strong>{stage.label}</strong>
<small>{stage.detail}</small>
</span>
</button>
))}
</nav>
<div className="rail-note">
<WarningCircleIcon size={18} weight={iconWeight} />
<p>{project
? project.plan.issues.filter((issue) => !issue.resolved).length > 0
? `还有 ${project.plan.issues.filter((issue) => !issue.resolved).length} 项图纸问题需要确认。`
: "当前阶段的信息已确认。"
: "请先导入一份 PDF 户型图。"}</p>
</div>
</aside>
<section className="workspace">
<Tabs.Root defaultValue="plan" className="workspace-tabs">
<div className="workspace-toolbar">
<Tabs.List className="tab-list" aria-label="工作区视图">
<Tabs.Trigger className="tab-trigger" value="plan">
<SlidersHorizontalIcon size={16} />
</Tabs.Trigger>
<Tabs.Trigger className="tab-trigger" value="blockout">
<CubeIcon size={16} /> 3D
</Tabs.Trigger>
<Tabs.Trigger className="tab-trigger" value="renders">
<ImageIcon size={16} />
</Tabs.Trigger>
</Tabs.List>
<div className="canvas-actions">
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button className="icon-button" type="button" aria-label="适应画布">
<ArrowsOutIcon size={18} />
</button>
</Tooltip.Trigger>
<Tooltip.Portal><Tooltip.Content className="tooltip"></Tooltip.Content></Tooltip.Portal>
</Tooltip.Root>
<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">
{!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>
) : (
<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>
<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>
<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">
<CubeIcon size={38} weight="thin" />
<h2></h2>
<p></p>
</Tabs.Content>
<Tabs.Content value="renders" className="tab-content empty-workspace">
<ImageIcon size={38} weight="thin" />
<h2></h2>
<p></p>
</Tabs.Content>
</Tabs.Root>
<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" ? (
<span className="assistant-icon"><SparkleIcon size={15} weight="fill" /></span>
) : null}
<div>
{message.meta ? <small>{message.meta}</small> : null}
<p>{message.body}</p>
</div>
</article>
))}
</div>
<form className="composer" onSubmit={sendMessage}>
<label htmlFor="design-instruction"></label>
<div className="composer-row">
<input
id="design-instruction"
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder={project ? "例如:保留床和窗的位置,客厅更松弛,不要冷灰" : "导入图纸后开始对话"}
disabled={!project}
/>
<button className="send-button" type="submit" aria-label="发送设计指令" disabled={!project}>
<PaperPlaneTiltIcon size={18} weight="fill" />
</button>
</div>
</form>
</section>
</section>
<aside className="style-inspector">
<div className="inspector-heading">
<div>
<p className="panel-title">Style DNA</p>
<p className="panel-caption"></p>
</div>
<span className="lock-label"><LockSimpleIcon size={13} /> 2 </span>
</div>
<div className="style-section">
<label htmlFor="style-concept"></label>
<textarea key={project?.project_id ?? "empty"} id="style-concept" defaultValue={project?.style.concept ?? "导入图纸后建立 Style DNA"} disabled={!project} />
</div>
<div className="style-section">
<p className="style-label"></p>
<div className="direction-list">
{styleDirections.map((direction) => (
<button
type="button"
key={direction.id}
className={`direction-option ${selectedDirection === direction.id ? "selected" : ""}`}
onClick={() => setSelectedDirection(direction.id)}
>
<span className="direction-colors" aria-hidden="true">
{direction.colors.map((color) => (
<span key={color} style={{ backgroundColor: color }} />
))}
</span>
<span>
<strong>{direction.name}</strong>
<small>{direction.summary}</small>
</span>
{selectedDirection === direction.id ? <CheckIcon size={15} weight="bold" /> : null}
</button>
))}
</div>
</div>
<div className="style-section material-section">
<p className="style-label"></p>
<div className="tag-list">
{currentDirection.materials.map((material) => <span key={material}>{material}</span>)}
<button type="button"><PlusIcon size={13} /> </button>
</div>
</div>
<div className="style-section constraint-section">
<p className="style-label"></p>
<p></p>
</div>
<div className="inspector-footer">
<div>
<CircleNotchIcon size={17} />
<span></span>
</div>
<button className="button button-primary full" type="button"></button>
</div>
</aside>
<main className="product-shell">
<header className="product-topbar">
<div className="product-brand"><span><BuildingsIcon size={19} weight="fill" /></span><div><strong> AI</strong><small>{project?.name ?? "住宅风格设计工作台"}</small></div></div>
<div className="product-top-actions">
{project ? <button className="product-button ghost" type="button" onClick={() => setProject(null)}><PlusIcon size={15} /></button> : null}
<button className="product-button secondary" type="button" onClick={() => setSettingsOpen(true)}><GearSixIcon size={16} /></button>
</div>
<SettingsCenter
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
onReadinessChange={setReadiness}
/>
</main>
</Tooltip.Provider>
<input ref={fileInput} className="visually-hidden" type="file" accept="application/pdf,.pdf" onChange={onFile} />
</header>
<div className="product-body">
<aside className="product-rail">
<div className="rail-progress"><strong>{project ? activeStage + 1 : 0}</strong><span>/ {STAGES.length}</span></div>
<nav>{STAGES.map((stage, index) => <div key={stage.label} className={index < activeStage ? "complete" : index === activeStage && project ? "active" : "pending"}><span>{index < activeStage ? <CheckIcon size={12} weight="bold" /> : index + 1}</span><p><strong>{stage.label}</strong><small>{stage.detail}</small></p></div>)}</nav>
{project ? <button className="change-plan" type="button" onClick={() => fileInput.current?.click()}><UploadSimpleIcon size={15} /></button> : null}
</aside>
<div className="product-center">
{busy ? <div className="busy-banner"><CircleNotchIcon className="spin" size={16} /><span>{busy}</span></div> : null}
{error ? <div className="error-banner"><WarningCircleIcon size={17} /><span>{error}</span><button type="button" onClick={() => setError("")}></button></div> : null}
{renderWorkspace()}
</div>
<aside className="agent-panel">
<div className="agent-heading"><span><SparkleIcon size={15} weight="fill" /></span><div><strong> Agent</strong><small>{readiness?.ready ? "模型与存储已就绪" : "等待系统配置"}</small></div></div>
<div className="agent-stream">
{!project ? <div className="agent-message"><p></p></div> : null}
{project?.events.slice(-6).map((event) => <div key={event.id} className={`agent-message ${event.kind === "user" ? "user" : ""}`}><small>{event.kind === "user" ? "你" : event.kind === "assistant" ? "设计 Agent" : "工作记录"}</small><p>{event.message}</p></div>)}
{chatReply && !project?.events.some((event) => event.message === chatReply) ? <div className="agent-message"><p>{chatReply}</p></div> : null}
</div>
<form className="agent-input" onSubmit={chat}><textarea value={chatDraft} onChange={(event) => setChatDraft(event.target.value)} placeholder={project ? "告诉我你的偏好,或问我下一步怎么选" : "先上传图纸后开始对话"} disabled={!project || Boolean(busy)} /><button type="submit" disabled={!project || !chatDraft.trim() || Boolean(busy)} aria-label="发送"><PaperPlaneTiltIcon size={16} weight="fill" /></button></form>
</aside>
</div>
<SettingsCenter open={settingsOpen} onClose={() => setSettingsOpen(false)} onReadinessChange={setReadiness} />
</main>
);
}
-25
View File
@@ -1,7 +1,6 @@
import type {
ChatMessage,
PlanLayer,
StyleDirection,
WorkflowStage,
} from "@/types/workflow";
@@ -24,30 +23,6 @@ export const initialLayers: PlanLayer[] = [
{ id: "labels", label: "房间文字", visible: false },
];
export const styleDirections: StyleDirection[] = [
{
id: "quiet-modern",
name: "清透现代",
summary: "轻盈体块、浅木与柔和自然光,保持客餐厅的开阔关系。",
colors: ["#E7E9E5", "#B8A68A", "#56695F"],
materials: ["浅橡木", "亚麻", "哑光涂料"],
},
{
id: "soft-architectural",
name: "柔和构成",
summary: "用低矮家具和圆角收束空间,重点塑造入口到客厅的连续视线。",
colors: ["#D9D7D0", "#8F8B82", "#3E5149"],
materials: ["微水泥", "烟熏木", "织物"],
},
{
id: "natural-contrast",
name: "自然对比",
summary: "增加深木与石材对比,但限制高光材质,避免空间变得沉重。",
colors: ["#E5E1D7", "#8B735F", "#31443B"],
materials: ["洞石", "深木", "羊毛"],
},
];
export const initialMessages: ChatMessage[] = [
{
id: "assistant-1",
+56 -2
View File
@@ -62,6 +62,25 @@ export interface PlanState {
issues: PlanIssue[];
scale_mm_per_unit?: number | null;
ceiling_height_mm: number;
gross_area_sqm?: number | null;
structure: {
status: string;
summary: string;
rooms: Array<{
id: string;
name: string;
kind: string;
area_sqm?: number | null;
confidence: number;
notes: string;
}>;
circulation: string;
daylight: string;
layout_opportunities: string[];
risks: string[];
model_name: string;
degraded: boolean;
};
}
export interface ProjectSnapshot {
@@ -86,17 +105,52 @@ export interface ProjectSnapshot {
density: string;
avoid: string[];
locked_decisions: string[];
selected_direction_id?: string | null;
};
brief: DesignBrief;
directions: StyleDirection[];
renders: RenderAsset[];
events: Array<{ id: string; kind: string; message: string; created_at: string }>;
available_commands: string[];
updated_at: string;
}
export interface DesignBrief {
residents: string;
lifestyle: string[];
focus_rooms: string[];
preferred_styles: string[];
preferred_colors: string[];
disliked_elements: string[];
must_keep: string[];
budget_level: string;
additional_notes: string;
completed: boolean;
}
export interface RenderAsset {
id: string;
direction_id: string;
room: string;
view: string;
object_key: string;
prompt: string;
model_name: string;
parent_id?: string | null;
edit_instruction: string;
created_at: string;
}
export interface StyleDirection {
id: string;
name: string;
summary: string;
colors: string[];
thesis: string;
keywords: string[];
palette: Array<{ name: string; hex: string; role: string }>;
materials: string[];
lighting: string;
prompt: string;
model_name: string;
}
export interface ChatMessage {