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
+9 -4
View File
@@ -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)。
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 {
+15
View File
@@ -13,6 +13,10 @@
"plan": {},
"scene": {},
"style": {},
"brief": {},
"directions": [],
"renders": [],
"events": [],
"available_commands": ["confirm_plan"],
"updated_at": "2026-08-01T12:00:00Z"
}
@@ -47,3 +51,14 @@
- `GET /v1/projects/{project_id}/preview`:从 MinIO 派生文件桶读取 PDF 首页预览。
- 新项目初始阶段为 `region_selection``plan.regions` 提供归一化的 `[x0, y0, x1, y1]` 候选区域。
- `select_region` 必须引用真实存在的候选区域 ID,成功后修订号递增并进入 `plan_review`
## Product Workflow
- `POST /v1/projects/{project_id}/spatial-analysis`:裁剪已选户型并调用空间理解模型;模型异常时返回 `degraded=true` 的可编辑房间草案。
- `POST /v1/projects/{project_id}/style-directions`:保存需求访谈并调用总调度模型生成三套 Style DNA。
- `POST /v1/projects/{project_id}/chat`:多轮设计对话,返回回复并把可识别的偏好更新到 `brief`
- `POST /v1/projects/{project_id}/renders`:调用模型池中的真实生图模型,下载并验证结果后写入渲染桶。
- `GET /v1/projects/{project_id}/renders/{asset_id}`:读取已持久化效果图。
- `POST /v1/projects/{project_id}/renders/{asset_id}/edit`:以历史效果图为参考执行文字修改,生成新的版本资产。
生图和编辑接口只在用户主动操作时调用收费模型。所有生成资产先验证为真实图片,再写入 MinIO 并加入 `ProjectSnapshot.renders`
+337 -1
View File
@@ -1,18 +1,23 @@
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import uuid4
import base64
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile, status
from pydantic import BaseModel
from pydantic import BaseModel, Field
from app.config import Settings, get_settings
from app.api.settings import require_workflow_ready
from app.domain.models import (
CommandRequest,
DesignBrief,
PlanIssue,
PlanLayer,
PlanState,
ProjectSnapshot,
ProjectEvent,
RenderAsset,
WorkflowDefinition,
WorkflowStage,
)
@@ -24,11 +29,23 @@ from app.domain.workflow import (
workflow_definition,
)
from app.integrations.model_router import ModelRouter
from app.integrations.openai_compatible import OpenAICompatibleGateway
from app.integrations.ocr import BaiduOcrAdapter
from app.integrations.storage import S3Storage
from app.repositories.postgres import postgres_repository
from app.runtime_settings import EncryptedSettingsStore, get_runtime_store
from app.services.plan_ingestion import inspect_pdf
from app.services.design_pipeline import (
analyze_space,
create_style_directions,
crop_plan_preview,
design_chat,
fallback_structure,
generated_image_bytes,
model_display_name,
render_id,
resolve_model_instance,
)
router = APIRouter(prefix="/v1")
MAX_UPLOAD_BYTES = 25 * 1024 * 1024
@@ -39,6 +56,38 @@ class UploadRequest(BaseModel):
content_type: str
class SpatialAnalysisRequest(BaseModel):
model_instance_id: str = ""
gross_area_sqm: float | None = Field(default=None, gt=0, le=2000)
class StyleDirectionRequest(BaseModel):
brief: DesignBrief
class RenderRequest(BaseModel):
direction_id: str
room: str = "客餐厅"
view: str = "入口看向客厅"
model_instance_id: str = ""
aspect_ratio: str = "16:9"
size: str = "1K"
class RenderEditRequest(BaseModel):
instruction: str = Field(min_length=2, max_length=1000)
model_instance_id: str = ""
class DesignChatRequest(BaseModel):
message: str = Field(min_length=1, max_length=2000)
class DesignChatResponse(BaseModel):
project: ProjectSnapshot
reply: str
def get_project_repository(settings: Settings = Depends(get_settings)):
return postgres_repository(settings.database_url)
@@ -116,6 +165,293 @@ def execute_command(
return repository.save(updated)
def _event(kind: str, message: str) -> ProjectEvent:
return ProjectEvent(id=f"event-{uuid4().hex[:10]}", kind=kind, message=message)
def _selected_region(project: ProjectSnapshot):
return next(
(region for region in project.plan.regions if region.id == project.plan.selected_region_id),
None,
)
@router.post(
"/projects/{project_id}/chat",
response_model=DesignChatResponse,
dependencies=[Depends(require_workflow_ready)],
)
async def chat_with_designer(
project_id: str,
request: DesignChatRequest,
runtime_store: EncryptedSettingsStore = Depends(get_runtime_store),
repository: Any = Depends(get_project_repository),
) -> DesignChatResponse:
project = repository.get(project_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found.")
selected = next(
(item for item in project.directions if item.id == project.style.selected_direction_id),
None,
)
reply, updates = await design_chat(
runtime_store.merged_values(),
stage=project.stage.value,
message=request.message,
brief=project.brief,
structure=project.plan.structure,
selected_direction=selected,
)
allowed = {
"residents",
"lifestyle",
"focus_rooms",
"preferred_styles",
"preferred_colors",
"disliked_elements",
"must_keep",
"budget_level",
"additional_notes",
}
merged = project.brief.model_dump()
merged.update({key: value for key, value in updates.items() if key in allowed})
project.brief = DesignBrief.model_validate(merged)
project.events.extend(
[
_event("user", request.message),
_event("assistant", reply),
]
)
project.revision += 1
project.updated_at = datetime.now(UTC)
return DesignChatResponse(project=repository.save(project), reply=reply)
@router.post(
"/projects/{project_id}/spatial-analysis",
response_model=ProjectSnapshot,
dependencies=[Depends(require_workflow_ready)],
)
async def run_spatial_analysis(
project_id: str,
request: SpatialAnalysisRequest,
runtime_store: EncryptedSettingsStore = Depends(get_runtime_store),
repository: Any = Depends(get_project_repository),
) -> ProjectSnapshot:
project = repository.get(project_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found.")
if project.stage != WorkflowStage.PLAN_REVIEW or not project.plan.selected_region_id:
raise HTTPException(status_code=422, detail="请先选择目标户型区域。")
values = runtime_store.merged_values()
storage = S3Storage(values)
try:
preview, _ = storage.get_output(str(project.plan.preview_object_key))
cropped = crop_plan_preview(preview, _selected_region(project))
crop_key = f"projects/{project_id}/derived/selected-plan.png"
storage.put_output(crop_key, cropped, "image/png")
analysis = await analyze_space(
values,
cropped,
model_instance_id=request.model_instance_id,
gross_area_sqm=request.gross_area_sqm,
)
message = f"{analysis.model_name} 已完成空间理解,识别到 {len(analysis.rooms)} 个房间。"
except Exception as exc:
analysis = fallback_structure(str(exc))
message = "空间模型调用失败,已建立可编辑房间草案,不阻塞后续设计。"
project.plan.structure = analysis
if request.gross_area_sqm is not None:
project.plan.gross_area_sqm = request.gross_area_sqm
project.events.append(_event("spatial_analysis", message))
project.revision += 1
project.updated_at = datetime.now(UTC)
return repository.save(project)
@router.post(
"/projects/{project_id}/style-directions",
response_model=ProjectSnapshot,
dependencies=[Depends(require_workflow_ready)],
)
async def generate_style_directions(
project_id: str,
request: StyleDirectionRequest,
runtime_store: EncryptedSettingsStore = Depends(get_runtime_store),
repository: Any = Depends(get_project_repository),
) -> ProjectSnapshot:
project = repository.get(project_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found.")
if project.stage != WorkflowStage.STYLE_BRIEF:
raise HTTPException(status_code=422, detail="请先确认户型结构并生成空间骨架。")
brief = DesignBrief.model_validate({**request.brief.model_dump(), "completed": True})
directions, model_name = await create_style_directions(
runtime_store.merged_values(), brief, project.plan.structure
)
project.brief = brief
project.directions = directions
project.stage = WorkflowStage.DIRECTION_SELECTION
project.available_commands = available_commands(project.stage)
project.revision += 1
project.updated_at = datetime.now(UTC)
project.events.append(_event("style_directions", f"{model_name} 已生成三套风格方向。"))
return repository.save(project)
def _image_options(values: dict[str, Any], instance_id: str, request: RenderRequest) -> dict[str, Any]:
item = next(
(candidate for candidate in values.get("model_pool", []) if candidate.get("id") == instance_id),
{},
)
profile = item.get("image_parameter_profile", "generic")
if profile == "gpt_image_2":
return {"size": "1536x1024", "quality": "medium"}
return {"aspect_ratio": request.aspect_ratio, "size": request.size}
@router.post(
"/projects/{project_id}/renders",
response_model=ProjectSnapshot,
dependencies=[Depends(require_workflow_ready)],
)
async def generate_project_render(
project_id: str,
request: RenderRequest,
runtime_store: EncryptedSettingsStore = Depends(get_runtime_store),
repository: Any = Depends(get_project_repository),
) -> ProjectSnapshot:
project = repository.get(project_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found.")
direction = next((item for item in project.directions if item.id == request.direction_id), None)
if direction is None:
raise HTTPException(status_code=422, detail="请先选择有效的风格方向。")
if project.stage not in {WorkflowStage.RENDER_REVIEW, WorkflowStage.EDITING}:
raise HTTPException(status_code=422, detail="当前阶段不能生成效果图。")
values = runtime_store.merged_values()
instance_id = await resolve_model_instance(values, "image", request.model_instance_id)
storage = S3Storage(values)
preview, _ = storage.get_output(str(project.plan.preview_object_key))
cropped = crop_plan_preview(preview, _selected_region(project))
reference = f"data:image/png;base64,{base64.b64encode(cropped).decode('ascii')}"
rooms = "".join(room.name for room in project.plan.structure.rooms)
prompt = (
f"住宅室内设计效果图,空间:{request.room},视角:{request.view}"
f"设计方向:{direction.name}{direction.thesis}"
f"关键词:{''.join(direction.keywords)}。材质:{''.join(direction.materials)}"
f"照明:{direction.lighting}。户型包含:{rooms or '以参考平面图为准'}"
"严格尊重参考平面图的空间关系、门窗位置和主要动线,不新增不存在的门窗或房间。"
"真实住宅尺度,专业室内摄影,材质自然,色彩协调,不出现文字、水印和施工尺寸标注。"
)
options = _image_options(values, instance_id, request)
options["images"] = [reference]
try:
payload = await OpenAICompatibleGateway(values).generate_image(
prompt, model_instance_id=instance_id, **options
)
content, content_type = await generated_image_bytes(payload)
asset_id = render_id()
extension = "png" if "png" in content_type else "jpg"
object_key = f"projects/{project_id}/renders/{asset_id}.{extension}"
storage.put_render(object_key, content, content_type)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"真实效果图生成失败:{exc}") from exc
asset = RenderAsset(
id=asset_id,
direction_id=direction.id,
room=request.room,
view=request.view,
object_key=object_key,
prompt=prompt,
model_name=model_display_name(values, instance_id),
)
project.renders.append(asset)
project.stage = WorkflowStage.EDITING
project.available_commands = available_commands(project.stage)
project.revision += 1
project.updated_at = datetime.now(UTC)
project.events.append(_event("render", f"已生成 {request.room}{request.view} 效果图。"))
return repository.save(project)
@router.post(
"/projects/{project_id}/renders/{asset_id}/edit",
response_model=ProjectSnapshot,
dependencies=[Depends(require_workflow_ready)],
)
async def edit_project_render(
project_id: str,
asset_id: str,
request: RenderEditRequest,
runtime_store: EncryptedSettingsStore = Depends(get_runtime_store),
repository: Any = Depends(get_project_repository),
) -> ProjectSnapshot:
project = repository.get(project_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found.")
parent = next((item for item in project.renders if item.id == asset_id), None)
if parent is None:
raise HTTPException(status_code=404, detail="没有找到要修改的效果图版本。")
values = runtime_store.merged_values()
instance_id = await resolve_model_instance(values, "image", request.model_instance_id)
storage = S3Storage(values)
original, _ = storage.get_render(parent.object_key)
prompt = (
f"只修改以下内容:{request.instruction}"
"保持原图的空间结构、相机位置、门窗、家具尺度和未提及区域完全一致。"
"保持专业住宅摄影质感,不添加文字或水印。"
)
try:
payload = await OpenAICompatibleGateway(values).edit_image(
prompt, original, model_instance_id=instance_id
)
content, content_type = await generated_image_bytes(payload)
new_id = render_id()
extension = "png" if "png" in content_type else "jpg"
object_key = f"projects/{project_id}/renders/{new_id}.{extension}"
storage.put_render(object_key, content, content_type)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"局部修改失败:{exc}") from exc
project.renders.append(
RenderAsset(
id=new_id,
direction_id=parent.direction_id,
room=parent.room,
view=parent.view,
object_key=object_key,
prompt=prompt,
model_name=model_display_name(values, instance_id),
parent_id=parent.id,
edit_instruction=request.instruction,
)
)
project.revision += 1
project.updated_at = datetime.now(UTC)
project.events.append(_event("edit", f"已完成修改:{request.instruction}"))
return repository.save(project)
@router.get("/projects/{project_id}/renders/{asset_id}")
def get_project_render(
project_id: str,
asset_id: str,
runtime_store: EncryptedSettingsStore = Depends(get_runtime_store),
repository: Any = Depends(get_project_repository),
) -> Response:
project = repository.get(project_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found.")
asset = next((item for item in project.renders if item.id == asset_id), None)
if asset is None:
raise HTTPException(status_code=404, detail="Render not found.")
try:
content, content_type = S3Storage(runtime_store.merged_values()).get_render(asset.object_key)
except Exception as exc:
raise HTTPException(status_code=503, detail=f"读取效果图失败:{exc}") from exc
return Response(content=content, media_type=content_type, headers={"Cache-Control": "private, max-age=300"})
@router.post(
"/projects/ingest",
response_model=ProjectSnapshot,
+73
View File
@@ -61,6 +61,27 @@ class PlanIssue(BaseModel):
resolved: bool = False
class RoomProfile(BaseModel):
id: str
name: str
kind: str
area_sqm: float | None = None
confidence: float = Field(default=0.5, ge=0, le=1)
notes: str = ""
class StructureAnalysis(BaseModel):
status: str = "pending"
summary: str = ""
rooms: list[RoomProfile] = Field(default_factory=list)
circulation: str = ""
daylight: str = ""
layout_opportunities: list[str] = Field(default_factory=list)
risks: list[str] = Field(default_factory=list)
model_name: str = ""
degraded: bool = False
class PlanState(BaseModel):
source_name: str
source_kind: str
@@ -81,6 +102,8 @@ class PlanState(BaseModel):
issues: list[PlanIssue] = Field(default_factory=list)
scale_mm_per_unit: float | None = None
ceiling_height_mm: int = 2800
gross_area_sqm: float | None = None
structure: StructureAnalysis = Field(default_factory=StructureAnalysis)
class CameraView(BaseModel):
@@ -113,6 +136,52 @@ class StyleState(BaseModel):
density: str = "适度留白"
avoid: list[str] = Field(default_factory=list)
locked_decisions: list[str] = Field(default_factory=list)
selected_direction_id: str | None = None
class DesignBrief(BaseModel):
residents: str = ""
lifestyle: list[str] = Field(default_factory=list)
focus_rooms: list[str] = Field(default_factory=list)
preferred_styles: list[str] = Field(default_factory=list)
preferred_colors: list[str] = Field(default_factory=list)
disliked_elements: list[str] = Field(default_factory=list)
must_keep: list[str] = Field(default_factory=list)
budget_level: str = "适中"
additional_notes: str = ""
completed: bool = False
class StyleDirection(BaseModel):
id: str
name: str
thesis: str
keywords: list[str] = Field(default_factory=list)
palette: list[ColorToken] = Field(default_factory=list)
materials: list[str] = Field(default_factory=list)
lighting: str = ""
prompt: str = ""
model_name: str = ""
class RenderAsset(BaseModel):
id: str
direction_id: str
room: str
view: str
object_key: str
prompt: str
model_name: str
parent_id: str | None = None
edit_instruction: str = ""
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
class ProjectEvent(BaseModel):
id: str
kind: str
message: str
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
class ProjectSnapshot(BaseModel):
@@ -123,6 +192,10 @@ class ProjectSnapshot(BaseModel):
plan: PlanState
scene: SceneState = Field(default_factory=SceneState)
style: StyleState = Field(default_factory=StyleState)
brief: DesignBrief = Field(default_factory=DesignBrief)
directions: list[StyleDirection] = Field(default_factory=list)
renders: list[RenderAsset] = Field(default_factory=list)
events: list[ProjectEvent] = Field(default_factory=list)
available_commands: list[WorkflowCommand] = Field(default_factory=list)
last_stable_stage: WorkflowStage | None = None
failure_reason: str | None = None
+41
View File
@@ -2,8 +2,11 @@ from copy import deepcopy
from datetime import UTC, datetime
from app.domain.models import (
CameraView,
CommandRequest,
DesignBrief,
ProjectSnapshot,
RoomProfile,
WorkflowCommand,
WorkflowDefinition,
WorkflowStage,
@@ -91,13 +94,51 @@ def apply_command(project: ProjectSnapshot, request: CommandRequest) -> ProjectS
updated.plan.ceiling_height_mm = int(
request.payload.get("ceiling_height_mm", updated.plan.ceiling_height_mm)
)
if request.payload.get("gross_area_sqm") is not None:
updated.plan.gross_area_sqm = float(request.payload["gross_area_sqm"])
if request.payload.get("room_names"):
existing = {room.name: room for room in updated.plan.structure.rooms}
updated.plan.structure.rooms = [
existing.get(name)
or RoomProfile(
id=f"room-{index + 1}",
name=name,
kind="other",
confidence=1,
notes="用户确认",
)
for index, raw_name in enumerate(request.payload["room_names"])
if (name := str(raw_name).strip())
]
for issue in updated.plan.issues:
if issue.kind in {"scale", "ocr"}:
issue.resolved = True
elif request.command == WorkflowCommand.BUILD_BLOCKOUT:
room_names = [room.name for room in updated.plan.structure.rooms]
focus = room_names[:3] or ["客餐厅", "主卧", "入口"]
updated.scene.cameras = [
CameraView(id=f"camera-{index + 1}", label=f"{room}主视角", room=room)
for index, room in enumerate(focus)
]
elif request.command == WorkflowCommand.SUBMIT_STYLE_BRIEF:
updated.brief = DesignBrief.model_validate({**updated.brief.model_dump(), **request.payload, "completed": True})
for field in ("concept", "lighting", "forms", "density"):
if field in request.payload:
setattr(updated.style, field, request.payload[field])
for field in ("keywords", "materials", "avoid", "locked_decisions"):
if field in request.payload:
setattr(updated.style, field, list(request.payload[field]))
elif request.command == WorkflowCommand.SELECT_DIRECTION:
direction_id = str(request.payload.get("direction_id", ""))
direction = next((item for item in updated.directions if item.id == direction_id), None)
if direction is None:
raise InvalidTransitionError(f"Direction '{direction_id}' does not exist in this project.")
updated.style.selected_direction_id = direction_id
updated.style.concept = direction.thesis
updated.style.keywords = direction.keywords
updated.style.palette = direction.palette
updated.style.materials = direction.materials
updated.style.lighting = direction.lighting
updated.available_commands = available_commands(updated.stage)
return updated
+12
View File
@@ -87,6 +87,18 @@ class S3Storage:
)
return response["Body"].read(), response.get("ContentType", "application/octet-stream")
def put_render(self, object_key: str, content: bytes, content_type: str) -> None:
self._put(self._value("s3_bucket_renders"), object_key, content, content_type)
def get_render(self, object_key: str) -> tuple[bytes, str]:
if not self.configured:
raise RuntimeError("MinIO/S3 is not configured.")
response = self._client().get_object(
Bucket=self._value("s3_bucket_renders"),
Key=object_key,
)
return response["Body"].read(), response.get("ContentType", "application/octet-stream")
def _put(self, bucket: str, object_key: str, content: bytes, content_type: str) -> None:
if not self.configured:
raise RuntimeError("MinIO/S3 is not configured.")
@@ -0,0 +1,352 @@
import base64
import json
import re
from io import BytesIO
from typing import Any
from uuid import uuid4
import httpx
from PIL import Image
from app.domain.models import (
ColorToken,
DesignBrief,
PlanRegion,
RoomProfile,
StructureAnalysis,
StyleDirection,
)
from app.integrations.openai_compatible import OpenAICompatibleGateway
def crop_plan_preview(preview: bytes, region: PlanRegion | None) -> bytes:
image = Image.open(BytesIO(preview)).convert("RGB")
if region is not None:
x0, y0, x1, y1 = region.bounds
padding = 0.015
box = (
max(0, int((x0 - padding) * image.width)),
max(0, int((y0 - padding) * image.height)),
min(image.width, int((x1 + padding) * image.width)),
min(image.height, int((y1 + padding) * image.height)),
)
image = image.crop(box)
image.thumbnail((1536, 1536))
output = BytesIO()
image.save(output, format="PNG", optimize=True)
return output.getvalue()
def _json_from_model(payload: dict[str, Any]) -> dict[str, Any]:
try:
content = payload["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as exc:
raise ValueError("模型没有返回可读取的消息内容。") from exc
if isinstance(content, list):
content = "".join(
str(item.get("text", "")) for item in content if isinstance(item, dict)
)
text = str(content).strip()
fenced = re.search(r"```(?:json)?\s*(\{.*\})\s*```", text, re.DOTALL)
if fenced:
text = fenced.group(1)
else:
start, end = text.find("{"), text.rfind("}")
if start >= 0 and end > start:
text = text[start : end + 1]
parsed = json.loads(text)
if not isinstance(parsed, dict):
raise ValueError("模型返回内容不是 JSON 对象。")
return parsed
async def resolve_model_instance(values: dict[str, Any], route: str, explicit_id: str = "") -> str:
if explicit_id:
return explicit_id
capability = "spatial_understanding" if route == "spatial" else "image_generation"
mode_key = "spatial_routing_mode" if route == "spatial" else "image_routing_mode"
selected_key = "spatial_model_id" if route == "spatial" else "image_model_id"
if values.get(mode_key) == "manual" and values.get(selected_key):
return str(values[selected_key])
candidates = [
item
for item in values.get("model_pool", [])
if isinstance(item, dict)
and item.get("enabled", True)
and capability in item.get("capabilities", [])
]
if not candidates:
raise RuntimeError(f"没有支持 {capability} 的已启用模型。")
if len(candidates) == 1:
return str(candidates[0]["id"])
choices = [
{
"id": item.get("id"),
"name": item.get("name"),
"model_id": item.get("model_id"),
"capabilities": item.get("capabilities", []),
}
for item in candidates
]
task = "理解住宅平面图的空间关系" if route == "spatial" else "生成高审美住宅室内效果图"
prompt = (
f"为任务“{task}”从候选模型中选择一个。只返回 JSON:"
f"{{\"model_instance_id\":\"候选 id\"}}。候选:{json.dumps(choices, ensure_ascii=False)}"
)
try:
response = await OpenAICompatibleGateway(values).chat([{"role": "user", "content": prompt}])
selected = str(_json_from_model(response).get("model_instance_id") or "")
if any(item.get("id") == selected for item in candidates):
return selected
except Exception:
pass
return str(candidates[0]["id"])
def model_display_name(values: dict[str, Any], instance_id: str) -> str:
for item in values.get("model_pool", []):
if isinstance(item, dict) and item.get("id") == instance_id:
return str(item.get("name") or item.get("model_id") or instance_id)
return instance_id
async def analyze_space(
values: dict[str, Any],
preview: bytes,
*,
model_instance_id: str = "",
gross_area_sqm: float | None = None,
) -> StructureAnalysis:
instance_id = await resolve_model_instance(values, "spatial", model_instance_id)
encoded = base64.b64encode(preview).decode("ascii")
area_hint = f"已知建筑面积约 {gross_area_sqm} 平方米。" if gross_area_sqm else "建筑面积未知。"
prompt = f"""你是住宅空间设计师。请分析这张户型平面图,重点服务于布局和风格效果图,不做施工承诺。{area_hint}
只返回 JSON,不要解释,格式如下:
{{
"summary": "一句话户型判断",
"rooms": [{{"name":"客厅","kind":"living","area_sqm":20.0,"confidence":0.8,"notes":"采光或连接关系"}}],
"circulation": "主要动线判断",
"daylight": "采光判断",
"layout_opportunities": ["最多4条可利用的布局机会"],
"risks": ["最多4条需要用户确认的问题"]
}}
无法确定的面积请填 null,不能凭空假定墙体可拆。"""
response = await OpenAICompatibleGateway(values).chat(
[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{encoded}"},
},
],
}
],
vision=True,
model_instance_id=instance_id,
)
data = _json_from_model(response)
rooms = []
for index, item in enumerate(data.get("rooms", [])):
if not isinstance(item, dict) or not item.get("name"):
continue
rooms.append(
RoomProfile(
id=str(item.get("id") or f"room-{index + 1}"),
name=str(item["name"]),
kind=str(item.get("kind") or "other"),
area_sqm=item.get("area_sqm"),
confidence=float(item.get("confidence", 0.55)),
notes=str(item.get("notes") or ""),
)
)
return StructureAnalysis(
status="ready",
summary=str(data.get("summary") or "空间模型已完成初步识别,请人工确认。"),
rooms=rooms,
circulation=str(data.get("circulation") or ""),
daylight=str(data.get("daylight") or ""),
layout_opportunities=[str(item) for item in data.get("layout_opportunities", [])][:6],
risks=[str(item) for item in data.get("risks", [])][:6],
model_name=model_display_name(values, instance_id),
)
def fallback_structure(reason: str = "") -> StructureAnalysis:
defaults = [
("客厅", "living"),
("餐厅", "dining"),
("主卧", "bedroom"),
("次卧", "bedroom"),
("厨房", "kitchen"),
("卫生间", "bathroom"),
]
return StructureAnalysis(
status="needs_review",
summary="空间模型暂未给出可靠结果,已建立可编辑房间草案。",
rooms=[
RoomProfile(id=f"room-{index + 1}", name=name, kind=kind, confidence=0.3)
for index, (name, kind) in enumerate(defaults)
],
layout_opportunities=["先确认房间数量和主要公共区,再进入风格设计。"],
risks=[reason or "房间名称、面积与墙体属性需要人工确认。"],
degraded=True,
)
def _fallback_directions(brief: DesignBrief) -> list[StyleDirection]:
preferred = "".join(brief.preferred_styles) or "现代简约"
avoid = "".join(brief.disliked_elements) or "避免过度装饰"
return [
StyleDirection(
id="clear-modern",
name="清透现代",
thesis=f"{preferred}为基础,用低饱和中性色和清晰体块获得明亮、耐看的日常空间。",
keywords=["通透", "低饱和", "整洁体块"],
palette=[
ColorToken(name="雾白", hex="#E8E9E6", role="墙面"),
ColorToken(name="石墨灰", hex="#555B58", role="家具"),
ColorToken(name="苔绿", hex="#65796A", role="点缀"),
],
materials=["哑光乳胶漆", "浅灰石材", "烟熏木饰面"],
lighting="自然光优先,线性洗墙与低位落地灯补充层次",
prompt=f"清透现代住宅,{preferred},低饱和,克制体块,{avoid}",
),
StyleDirection(
id="soft-natural",
name="柔和自然",
thesis="弱化硬边界,用温和木色、织物和漫反射光让公共区更松弛,适合长期居住。",
keywords=["松弛", "木质", "柔光"],
palette=[
ColorToken(name="浅岩灰", hex="#D9D5CD", role="墙面"),
ColorToken(name="橡木", hex="#A58D70", role="木作"),
ColorToken(name="森林绿", hex="#40584A", role="点缀"),
],
materials=["自然橡木", "亚麻织物", "细纹微水泥"],
lighting="窗边自然光与隐藏式间接光结合,色温保持统一",
prompt=f"柔和自然住宅,{preferred},自然木材,亚麻,安静柔光,{avoid}",
),
StyleDirection(
id="graphic-contrast",
name="克制对比",
thesis="保持空间背景安静,用少量深色构件和艺术家具建立记忆点,画面更有设计感。",
keywords=["对比", "艺术家具", "干净线条"],
palette=[
ColorToken(name="冷白", hex="#ECEDEA", role="背景"),
ColorToken(name="炭黑", hex="#292D2B", role="构件"),
ColorToken(name="砖红", hex="#9A5547", role="点缀"),
],
materials=["冷灰涂料", "深色金属", "胡桃木"],
lighting="重点照明突出家具与材质,整体控制眩光",
prompt=f"克制对比住宅,{preferred},冷白背景,深色构件,艺术家具,{avoid}",
),
]
async def create_style_directions(
values: dict[str, Any], brief: DesignBrief, structure: StructureAnalysis
) -> tuple[list[StyleDirection], str]:
fallback = _fallback_directions(brief)
room_names = "".join(room.name for room in structure.rooms) or "户型房间待确认"
prompt = f"""你是资深住宅室内设计总监。根据以下信息生成三套差异明确但可落地的风格方向。
居住者:{brief.residents or '未填写'}
生活方式:{''.join(brief.lifestyle) or '未填写'}
重点空间:{''.join(brief.focus_rooms) or room_names}
偏好风格:{''.join(brief.preferred_styles) or '现代、自然'}
偏好颜色:{''.join(brief.preferred_colors) or '低饱和中性色'}
不喜欢:{''.join(brief.disliked_elements) or '过度装饰'}
保留项:{''.join(brief.must_keep) or ''}
预算:{brief.budget_level}
补充:{brief.additional_notes or ''}
只返回 JSON{{"directions":[{{"id":"英文短标识","name":"中文名","thesis":"一句设计主张","keywords":["3项"],"palette":[{{"name":"颜色名","hex":"#RRGGBB","role":"用途"}}],"materials":["3项"],"lighting":"照明策略","prompt":"适合图像模型的中文提示词"}}]}}。必须恰好三套。"""
try:
response = await OpenAICompatibleGateway(values).chat([{"role": "user", "content": prompt}])
data = _json_from_model(response)
model_name = model_display_name(values, str(values.get("orchestrator_model_id", "")))
directions = []
for index, item in enumerate(data.get("directions", [])):
if not isinstance(item, dict):
continue
directions.append(
StyleDirection(
id=str(item.get("id") or f"direction-{index + 1}"),
name=str(item.get("name") or fallback[index].name),
thesis=str(item.get("thesis") or fallback[index].thesis),
keywords=[str(value) for value in item.get("keywords", [])][:5],
palette=[ColorToken.model_validate(value) for value in item.get("palette", [])][:5],
materials=[str(value) for value in item.get("materials", [])][:6],
lighting=str(item.get("lighting") or ""),
prompt=str(item.get("prompt") or fallback[index].prompt),
model_name=model_name,
)
)
if len(directions) == 3:
return directions, model_name
except Exception:
pass
return fallback, "内置设计策略(模型降级)"
async def generated_image_bytes(payload: dict[str, Any]) -> tuple[bytes, str]:
url = str(payload.get("result_url") or "")
data = payload.get("data")
if not url and isinstance(data, list) and data and isinstance(data[0], dict):
url = str(data[0].get("url") or "")
encoded = data[0].get("b64_json")
if encoded:
return base64.b64decode(str(encoded)), "image/png"
if url.startswith("data:image/"):
header, encoded = url.split(",", 1)
mime = header.split(";", 1)[0].replace("data:", "")
return base64.b64decode(encoded), mime
if not url:
raise ValueError("生图模型返回了成功响应,但没有图片地址或图片数据。")
async with httpx.AsyncClient(timeout=90, follow_redirects=True) as client:
response = await client.get(url)
response.raise_for_status()
content_type = response.headers.get("content-type", "image/jpeg").split(";", 1)[0]
if not content_type.startswith("image/") or len(response.content) < 1024:
raise ValueError("生图结果不是可读取的真实图片。")
return response.content, content_type
def render_id() -> str:
return f"render-{uuid4().hex[:12]}"
async def design_chat(
values: dict[str, Any],
*,
stage: str,
message: str,
brief: DesignBrief,
structure: StructureAnalysis,
selected_direction: StyleDirection | None,
) -> tuple[str, dict[str, Any]]:
context = {
"stage": stage,
"brief": brief.model_dump(),
"rooms": [room.model_dump() for room in structure.rooms],
"structure_summary": structure.summary,
"selected_direction": selected_direction.model_dump() if selected_direction else None,
}
prompt = f"""你是一个住宅风格设计 Agent,当前项目上下文:
{json.dumps(context, ensure_ascii=False)}
用户说:{message}
请判断信息是否足够。需要追问时只追问一个最关键问题;能够执行时说明你记录了什么,以及下一步该点击什么。
只返回 JSON{{"reply":"自然、简短的中文回复","brief_updates":{{"residents":"可选","lifestyle":["可选"],"focus_rooms":["可选"],"preferred_styles":["可选"],"preferred_colors":["可选"],"disliked_elements":["可选"],"must_keep":["可选"],"budget_level":"可选","additional_notes":"可选"}}}}
不要承诺施工准确性,不要虚构用户没有表达的偏好。"""
try:
response = await OpenAICompatibleGateway(values).chat([{"role": "user", "content": prompt}])
data = _json_from_model(response)
reply = str(data.get("reply") or "已记录,我会把它作为后续设计约束。")
updates = data.get("brief_updates") if isinstance(data.get("brief_updates"), dict) else {}
return reply, updates
except Exception:
note = brief.additional_notes.strip()
combined = f"{note}\n{message}".strip() if note else message
return "已把这条要求记入项目。你可以继续补充,或按当前页面的主按钮进入下一步。", {
"additional_notes": combined
}
@@ -0,0 +1,66 @@
import base64
import pytest
from app.domain.models import DesignBrief, StructureAnalysis
from app.services.design_pipeline import (
create_style_directions,
fallback_structure,
generated_image_bytes,
resolve_model_instance,
)
@pytest.mark.asyncio
async def test_manual_model_routing_uses_selected_pool_item() -> None:
values = {
"image_routing_mode": "manual",
"image_model_id": "image-2",
"model_pool": [
{
"id": "image-2",
"enabled": True,
"capabilities": ["image_generation"],
}
],
}
assert await resolve_model_instance(values, "image") == "image-2"
@pytest.mark.asyncio
async def test_generated_image_bytes_accepts_verified_inline_image() -> None:
content = b"\x89PNG\r\n\x1a\n" + b"x" * 2048
payload = {
"data": [
{
"url": "data:image/png;base64,"
+ base64.b64encode(content).decode("ascii")
}
]
}
image, content_type = await generated_image_bytes(payload)
assert image == content
assert content_type == "image/png"
@pytest.mark.asyncio
async def test_style_direction_generation_has_non_blocking_fallback() -> None:
brief = DesignBrief(preferred_styles=["现代简约"], disliked_elements=["复杂吊顶"])
directions, model_name = await create_style_directions({}, brief, StructureAnalysis())
assert len(directions) == 3
assert all(direction.prompt for direction in directions)
assert "降级" in model_name
def test_spatial_fallback_remains_editable() -> None:
analysis = fallback_structure("provider unavailable")
assert analysis.degraded is True
assert analysis.status == "needs_review"
assert len(analysis.rooms) >= 6
assert analysis.risks == ["provider unavailable"]