feat: ship end-to-end interior design MVP
This commit is contained in:
@@ -33,17 +33,22 @@ docs 架构与工作流设计
|
|||||||
|
|
||||||
前端默认访问 `http://localhost:3000`,API 文档默认位于 `http://localhost:8000/docs`。
|
前端默认访问 `http://localhost:3000`,API 文档默认位于 `http://localhost:8000/docs`。
|
||||||
|
|
||||||
## 当前垂直切片
|
## 当前可体验产品
|
||||||
|
|
||||||
- 从网页真实上传 25 MB 以内的 PDF,并把原文件与首页预览保存到 MinIO。
|
- 从网页真实上传 25 MB 以内的 PDF,并把原文件与首页预览保存到 MinIO。
|
||||||
- 判断矢量 PDF / 扫描 PDF,统计矢量元素,并检测同页中的多个候选平面区域。
|
- 判断矢量 PDF / 扫描 PDF,统计矢量元素,并检测同页中的多个候选平面区域。
|
||||||
- 在图纸上直接选择目标户型,选择结果与工作流修订号持久化到 PostgreSQL。
|
- 在图纸上直接选择目标户型,选择结果与工作流修订号持久化到 PostgreSQL。
|
||||||
- 展示从导入、清洗、白模到风格方向和多轮修改的阶段状态。
|
- 调用空间理解模型分析目标户型,输出房间、动线、采光、布局机会和风险;失败时自动建立可编辑草案,不阻塞流程。
|
||||||
- 提供结构化 Style DNA 面板。
|
- 通过居住者、生活方式、重点空间、风格、颜色、禁用项和保留项完成需求访谈。
|
||||||
|
- 调用总调度模型生成三套结构化 Style DNA,并支持人工选择设计方向。
|
||||||
|
- 自动路由模式由总调度模型从候选池选择空间模型或生图模型;手动模式严格使用用户指定模型。
|
||||||
|
- 调用真实生图模型生成关键空间效果图,结果保存到 MinIO 独立渲染桶。
|
||||||
|
- 基于任意历史版本继续文字修改,保存父子版本关系,并可导出完整项目记录。
|
||||||
|
- 右侧设计 Agent 支持多轮对话、追问和需求记录。
|
||||||
- 提供可验证的工作流状态机和命令接口。
|
- 提供可验证的工作流状态机和命令接口。
|
||||||
- 预留 MinIO、百度 OCR、GPU Worker 和多模型路由适配器。
|
- 预留 MinIO、百度 OCR、GPU Worker 和多模型路由适配器。
|
||||||
- 提供分类设置中心、可扩展模型池、逐模型测试、模型路由和工作流就绪门禁。
|
- 提供分类设置中心、可扩展模型池、逐模型测试、模型路由和工作流就绪门禁。
|
||||||
|
|
||||||
当前图纸解析先处理 PDF 首页并完成“导入图纸 → 选择户型 → 进入结构确认”的真实闭环。墙、门窗、尺寸比例和房间多边形的语义分离属于下一阶段。
|
当前产品定位是布局与风格概念验证,不替代酷家乐等施工设计工具。墙体可拆性、精确尺寸、施工节点和商品匹配仍需专业设计工具或人工复核。
|
||||||
|
|
||||||
详见 [架构设计](docs/architecture.md)、[工作流定义](docs/workflow.md) 与 [系统设置](docs/settings.md)。
|
详见 [架构设计](docs/architecture.md)、[工作流定义](docs/workflow.md) 与 [系统设置](docs/settings.md)。
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as Tabs from "@radix-ui/react-tabs";
|
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
|
||||||
import {
|
import {
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
ArrowsOutIcon,
|
|
||||||
BuildingsIcon,
|
BuildingsIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
CircleNotchIcon,
|
CircleNotchIcon,
|
||||||
CubeIcon,
|
CubeIcon,
|
||||||
EyeIcon,
|
DownloadSimpleIcon,
|
||||||
EyeSlashIcon,
|
|
||||||
GearSixIcon,
|
GearSixIcon,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
LockSimpleIcon,
|
MagicWandIcon,
|
||||||
PaperPlaneTiltIcon,
|
PaperPlaneTiltIcon,
|
||||||
|
PencilSimpleIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
SlidersHorizontalIcon,
|
|
||||||
SparkleIcon,
|
SparkleIcon,
|
||||||
UploadSimpleIcon,
|
UploadSimpleIcon,
|
||||||
WarningCircleIcon,
|
WarningCircleIcon,
|
||||||
@@ -24,493 +20,492 @@ import {
|
|||||||
import { ChangeEvent, FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
import { ChangeEvent, FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { SettingsCenter } from "@/components/settings-center";
|
import { SettingsCenter } from "@/components/settings-center";
|
||||||
import { styleDirections } from "@/lib/demo";
|
import type { DesignBrief, ProjectSnapshot, RenderAsset, WorkflowStageId } from "@/types/workflow";
|
||||||
import type { ChatMessage, PlanLayer, ProjectSnapshot, WorkflowStage } from "@/types/workflow";
|
|
||||||
import type { SettingsReadiness } from "@/types/settings";
|
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 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 原文件" },
|
const EMPTY_BRIEF: DesignBrief = {
|
||||||
{ id: "region_selection", label: "选择户型", detail: "候选区域" },
|
residents: "",
|
||||||
{ id: "plan_review", label: "确认结构", detail: "墙、门窗与比例" },
|
lifestyle: [],
|
||||||
{ id: "blockout", label: "生成白模", detail: "空间骨架" },
|
focus_rooms: [],
|
||||||
{ id: "style_brief", label: "明确风格", detail: "Style DNA" },
|
preferred_styles: [],
|
||||||
{ id: "direction_selection", label: "选择方向", detail: "三个方案" },
|
preferred_colors: [],
|
||||||
{ id: "render_review", label: "审阅效果", detail: "多视角" },
|
disliked_elements: [],
|
||||||
{ id: "editing", label: "多轮修改", detail: "局部编辑" },
|
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() {
|
export function WorkflowStudio() {
|
||||||
const [project, setProject] = useState<ProjectSnapshot | null>(null);
|
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 [readiness, setReadiness] = useState<SettingsReadiness | null>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [workflowError, setWorkflowError] = useState<string | null>(null);
|
const [loading, setLoading] = useState(true);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
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(
|
const activeStage = stageIndex(project?.stage);
|
||||||
() => styleDirections.find((item) => item.id === selectedDirection) ?? styleDirections[0],
|
const selectedDirection = useMemo(
|
||||||
[selectedDirection],
|
() => project?.directions.find((item) => item.id === project.style.selected_direction_id) ?? null,
|
||||||
);
|
|
||||||
|
|
||||||
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,
|
|
||||||
[project],
|
[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) {
|
function applyProject(next: ProjectSnapshot) {
|
||||||
setProject(next);
|
setProject(next);
|
||||||
setLayers(next.plan.layers.map((layer) => ({ ...layer, count: layer.element_count ?? undefined })));
|
setBrief(next.brief ?? EMPTY_BRIEF);
|
||||||
setMessages([
|
setGrossArea(next.plan.gross_area_sqm ? String(next.plan.gross_area_sqm) : "");
|
||||||
...next.plan.ingestion_notes.map((body, index) => ({
|
setCeilingHeight(String(next.plan.ceiling_height_mm || 2800));
|
||||||
id: `ingestion-${index}`,
|
setRoomNames(next.plan.structure?.rooms?.map((room) => room.name).join("、") ?? "");
|
||||||
role: "assistant" as const,
|
if (next.plan.structure?.rooms?.[0]?.name) setRenderRoom(next.plan.structure.rooms[0].name);
|
||||||
body,
|
if (next.renders.length) setActiveRenderId(next.renders[next.renders.length - 1].id);
|
||||||
meta: "图纸解析",
|
|
||||||
})),
|
|
||||||
...next.plan.issues.filter((issue) => !issue.resolved).map((issue) => ({
|
|
||||||
id: `issue-${issue.id}`,
|
|
||||||
role: "assistant" as const,
|
|
||||||
body: issue.message,
|
|
||||||
meta: "等待确认",
|
|
||||||
})),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadReadiness() {
|
async function boot() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/v1/readiness`, { cache: "no-store" });
|
const [readinessResponse, projectsResponse] = await Promise.all([
|
||||||
if (!response.ok) return;
|
fetch(`${API_BASE}/v1/readiness`, { cache: "no-store" }),
|
||||||
const current = await response.json() as SettingsReadiness;
|
fetch(`${API_BASE}/v1/projects`, { cache: "no-store" }),
|
||||||
setReadiness(current);
|
]);
|
||||||
if (!current.ready && !window.sessionStorage.getItem("settings-intro-seen")) {
|
if (readinessResponse.ok) {
|
||||||
window.sessionStorage.setItem("settings-intro-seen", "true");
|
const current = await readinessResponse.json() as SettingsReadiness;
|
||||||
setSettingsOpen(true);
|
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 {
|
} catch {
|
||||||
setReadiness(null);
|
setError("无法连接本地服务,请检查 Docker 是否正在运行。");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void loadReadiness();
|
void boot();
|
||||||
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);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function readApiError(response: Response, fallback: string) {
|
async function apiError(response: Response, fallback: string) {
|
||||||
try {
|
try {
|
||||||
const payload = await response.json() as { detail?: string | { message?: string } };
|
const body = await response.json() as { detail?: string | { message?: string } };
|
||||||
if (typeof payload.detail === "string") return payload.detail;
|
if (typeof body.detail === "string") return body.detail;
|
||||||
if (payload.detail?.message) return payload.detail.message;
|
if (body.detail?.message) return body.detail.message;
|
||||||
} catch {
|
} catch { /* use fallback */ }
|
||||||
// Keep the readable fallback.
|
|
||||||
}
|
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadPlan(file: File) {
|
async function command(base: ProjectSnapshot, name: string, payload: Record<string, unknown> = {}) {
|
||||||
if (!readiness?.ready) {
|
const response = await fetch(`${API_BASE}/v1/projects/${base.project_id}/commands`, {
|
||||||
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`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ command: name, expected_revision: base.revision, payload }),
|
||||||
command: "select_region",
|
|
||||||
expected_revision: project.revision,
|
|
||||||
payload: { region_id: regionId },
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) throw new Error(await apiError(response, "工作流操作失败。"));
|
||||||
setWorkflowError(await readApiError(response, "候选户型选择失败。"));
|
return response.json() as Promise<ProjectSnapshot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload(file: File) {
|
||||||
|
if (!readiness?.ready) {
|
||||||
|
setSettingsOpen(true);
|
||||||
return;
|
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) {
|
function onFile(event: ChangeEvent<HTMLInputElement>) {
|
||||||
setLayers((items) =>
|
const file = event.target.files?.[0];
|
||||||
items.map((item) => (item.id === id ? { ...item, visible: !item.visible } : item)),
|
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();
|
event.preventDefault();
|
||||||
const value = draft.trim();
|
if (!project || !activeRender || !editInstruction.trim()) return;
|
||||||
if (!value) return;
|
setBusy("正在只修改指定内容,并保持其他区域不变");
|
||||||
setMessages((items) => [
|
setError("");
|
||||||
...items,
|
try {
|
||||||
{ id: `user-${Date.now()}`, role: "user", body: value },
|
const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/renders/${activeRender.id}/edit`, {
|
||||||
{
|
method: "POST",
|
||||||
id: `assistant-${Date.now()}`,
|
headers: { "Content-Type": "application/json" },
|
||||||
role: "assistant",
|
body: JSON.stringify({ instruction: editInstruction.trim() }),
|
||||||
body: "已记录为设计约束。正式接入后,这条指令会先更新布局锁定和 Style DNA,再只重绘受影响视角。",
|
});
|
||||||
meta: "结构化修改",
|
if (!response.ok) throw new Error(await apiError(response, "效果图修改失败。"));
|
||||||
},
|
applyProject(await response.json() as ProjectSnapshot);
|
||||||
]);
|
setEditInstruction("");
|
||||||
setDraft("");
|
} 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 (
|
return (
|
||||||
<Tooltip.Provider delayDuration={250}>
|
<main className="product-shell">
|
||||||
<main className="studio-shell">
|
<header className="product-topbar">
|
||||||
<header className="topbar">
|
<div className="product-brand"><span><BuildingsIcon size={19} weight="fill" /></span><div><strong>住境 AI</strong><small>{project?.name ?? "住宅风格设计工作台"}</small></div></div>
|
||||||
<div className="brand-block">
|
<div className="product-top-actions">
|
||||||
<div className="brand-mark" aria-hidden="true">
|
{project ? <button className="product-button ghost" type="button" onClick={() => setProject(null)}><PlusIcon size={15} />新方案</button> : null}
|
||||||
<BuildingsIcon size={19} weight="fill" />
|
<button className="product-button secondary" type="button" onClick={() => setSettingsOpen(true)}><GearSixIcon size={16} />系统设置</button>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
<SettingsCenter
|
<input ref={fileInput} className="visually-hidden" type="file" accept="application/pdf,.pdf" onChange={onFile} />
|
||||||
open={settingsOpen}
|
</header>
|
||||||
onClose={() => setSettingsOpen(false)}
|
|
||||||
onReadinessChange={setReadiness}
|
<div className="product-body">
|
||||||
/>
|
<aside className="product-rail">
|
||||||
</main>
|
<div className="rail-progress"><strong>{project ? activeStage + 1 : 0}</strong><span>/ {STAGES.length}</span></div>
|
||||||
</Tooltip.Provider>
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
PlanLayer,
|
PlanLayer,
|
||||||
StyleDirection,
|
|
||||||
WorkflowStage,
|
WorkflowStage,
|
||||||
} from "@/types/workflow";
|
} from "@/types/workflow";
|
||||||
|
|
||||||
@@ -24,30 +23,6 @@ export const initialLayers: PlanLayer[] = [
|
|||||||
{ id: "labels", label: "房间文字", visible: false },
|
{ 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[] = [
|
export const initialMessages: ChatMessage[] = [
|
||||||
{
|
{
|
||||||
id: "assistant-1",
|
id: "assistant-1",
|
||||||
|
|||||||
@@ -62,6 +62,25 @@ export interface PlanState {
|
|||||||
issues: PlanIssue[];
|
issues: PlanIssue[];
|
||||||
scale_mm_per_unit?: number | null;
|
scale_mm_per_unit?: number | null;
|
||||||
ceiling_height_mm: number;
|
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 {
|
export interface ProjectSnapshot {
|
||||||
@@ -86,17 +105,52 @@ export interface ProjectSnapshot {
|
|||||||
density: string;
|
density: string;
|
||||||
avoid: string[];
|
avoid: string[];
|
||||||
locked_decisions: 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[];
|
available_commands: string[];
|
||||||
updated_at: 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 {
|
export interface StyleDirection {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
summary: string;
|
thesis: string;
|
||||||
colors: string[];
|
keywords: string[];
|
||||||
|
palette: Array<{ name: string; hex: string; role: string }>;
|
||||||
materials: string[];
|
materials: string[];
|
||||||
|
lighting: string;
|
||||||
|
prompt: string;
|
||||||
|
model_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
"plan": {},
|
"plan": {},
|
||||||
"scene": {},
|
"scene": {},
|
||||||
"style": {},
|
"style": {},
|
||||||
|
"brief": {},
|
||||||
|
"directions": [],
|
||||||
|
"renders": [],
|
||||||
|
"events": [],
|
||||||
"available_commands": ["confirm_plan"],
|
"available_commands": ["confirm_plan"],
|
||||||
"updated_at": "2026-08-01T12:00:00Z"
|
"updated_at": "2026-08-01T12:00:00Z"
|
||||||
}
|
}
|
||||||
@@ -47,3 +51,14 @@
|
|||||||
- `GET /v1/projects/{project_id}/preview`:从 MinIO 派生文件桶读取 PDF 首页预览。
|
- `GET /v1/projects/{project_id}/preview`:从 MinIO 派生文件桶读取 PDF 首页预览。
|
||||||
- 新项目初始阶段为 `region_selection`,`plan.regions` 提供归一化的 `[x0, y0, x1, y1]` 候选区域。
|
- 新项目初始阶段为 `region_selection`,`plan.regions` 提供归一化的 `[x0, y0, x1, y1]` 候选区域。
|
||||||
- `select_region` 必须引用真实存在的候选区域 ID,成功后修订号递增并进入 `plan_review`。
|
- `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`。
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import base64
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile, status
|
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.config import Settings, get_settings
|
||||||
from app.api.settings import require_workflow_ready
|
from app.api.settings import require_workflow_ready
|
||||||
from app.domain.models import (
|
from app.domain.models import (
|
||||||
CommandRequest,
|
CommandRequest,
|
||||||
|
DesignBrief,
|
||||||
PlanIssue,
|
PlanIssue,
|
||||||
PlanLayer,
|
PlanLayer,
|
||||||
PlanState,
|
PlanState,
|
||||||
ProjectSnapshot,
|
ProjectSnapshot,
|
||||||
|
ProjectEvent,
|
||||||
|
RenderAsset,
|
||||||
WorkflowDefinition,
|
WorkflowDefinition,
|
||||||
WorkflowStage,
|
WorkflowStage,
|
||||||
)
|
)
|
||||||
@@ -24,11 +29,23 @@ from app.domain.workflow import (
|
|||||||
workflow_definition,
|
workflow_definition,
|
||||||
)
|
)
|
||||||
from app.integrations.model_router import ModelRouter
|
from app.integrations.model_router import ModelRouter
|
||||||
|
from app.integrations.openai_compatible import OpenAICompatibleGateway
|
||||||
from app.integrations.ocr import BaiduOcrAdapter
|
from app.integrations.ocr import BaiduOcrAdapter
|
||||||
from app.integrations.storage import S3Storage
|
from app.integrations.storage import S3Storage
|
||||||
from app.repositories.postgres import postgres_repository
|
from app.repositories.postgres import postgres_repository
|
||||||
from app.runtime_settings import EncryptedSettingsStore, get_runtime_store
|
from app.runtime_settings import EncryptedSettingsStore, get_runtime_store
|
||||||
from app.services.plan_ingestion import inspect_pdf
|
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")
|
router = APIRouter(prefix="/v1")
|
||||||
MAX_UPLOAD_BYTES = 25 * 1024 * 1024
|
MAX_UPLOAD_BYTES = 25 * 1024 * 1024
|
||||||
@@ -39,6 +56,38 @@ class UploadRequest(BaseModel):
|
|||||||
content_type: str
|
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)):
|
def get_project_repository(settings: Settings = Depends(get_settings)):
|
||||||
return postgres_repository(settings.database_url)
|
return postgres_repository(settings.database_url)
|
||||||
|
|
||||||
@@ -116,6 +165,293 @@ def execute_command(
|
|||||||
return repository.save(updated)
|
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(
|
@router.post(
|
||||||
"/projects/ingest",
|
"/projects/ingest",
|
||||||
response_model=ProjectSnapshot,
|
response_model=ProjectSnapshot,
|
||||||
|
|||||||
@@ -61,6 +61,27 @@ class PlanIssue(BaseModel):
|
|||||||
resolved: bool = False
|
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):
|
class PlanState(BaseModel):
|
||||||
source_name: str
|
source_name: str
|
||||||
source_kind: str
|
source_kind: str
|
||||||
@@ -81,6 +102,8 @@ class PlanState(BaseModel):
|
|||||||
issues: list[PlanIssue] = Field(default_factory=list)
|
issues: list[PlanIssue] = Field(default_factory=list)
|
||||||
scale_mm_per_unit: float | None = None
|
scale_mm_per_unit: float | None = None
|
||||||
ceiling_height_mm: int = 2800
|
ceiling_height_mm: int = 2800
|
||||||
|
gross_area_sqm: float | None = None
|
||||||
|
structure: StructureAnalysis = Field(default_factory=StructureAnalysis)
|
||||||
|
|
||||||
|
|
||||||
class CameraView(BaseModel):
|
class CameraView(BaseModel):
|
||||||
@@ -113,6 +136,52 @@ class StyleState(BaseModel):
|
|||||||
density: str = "适度留白"
|
density: str = "适度留白"
|
||||||
avoid: list[str] = Field(default_factory=list)
|
avoid: list[str] = Field(default_factory=list)
|
||||||
locked_decisions: 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):
|
class ProjectSnapshot(BaseModel):
|
||||||
@@ -123,6 +192,10 @@ class ProjectSnapshot(BaseModel):
|
|||||||
plan: PlanState
|
plan: PlanState
|
||||||
scene: SceneState = Field(default_factory=SceneState)
|
scene: SceneState = Field(default_factory=SceneState)
|
||||||
style: StyleState = Field(default_factory=StyleState)
|
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)
|
available_commands: list[WorkflowCommand] = Field(default_factory=list)
|
||||||
last_stable_stage: WorkflowStage | None = None
|
last_stable_stage: WorkflowStage | None = None
|
||||||
failure_reason: str | None = None
|
failure_reason: str | None = None
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ from copy import deepcopy
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from app.domain.models import (
|
from app.domain.models import (
|
||||||
|
CameraView,
|
||||||
CommandRequest,
|
CommandRequest,
|
||||||
|
DesignBrief,
|
||||||
ProjectSnapshot,
|
ProjectSnapshot,
|
||||||
|
RoomProfile,
|
||||||
WorkflowCommand,
|
WorkflowCommand,
|
||||||
WorkflowDefinition,
|
WorkflowDefinition,
|
||||||
WorkflowStage,
|
WorkflowStage,
|
||||||
@@ -91,13 +94,51 @@ def apply_command(project: ProjectSnapshot, request: CommandRequest) -> ProjectS
|
|||||||
updated.plan.ceiling_height_mm = int(
|
updated.plan.ceiling_height_mm = int(
|
||||||
request.payload.get("ceiling_height_mm", updated.plan.ceiling_height_mm)
|
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:
|
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"):
|
for field in ("concept", "lighting", "forms", "density"):
|
||||||
if field in request.payload:
|
if field in request.payload:
|
||||||
setattr(updated.style, field, request.payload[field])
|
setattr(updated.style, field, request.payload[field])
|
||||||
for field in ("keywords", "materials", "avoid", "locked_decisions"):
|
for field in ("keywords", "materials", "avoid", "locked_decisions"):
|
||||||
if field in request.payload:
|
if field in request.payload:
|
||||||
setattr(updated.style, field, list(request.payload[field]))
|
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)
|
updated.available_commands = available_commands(updated.stage)
|
||||||
return updated
|
return updated
|
||||||
|
|||||||
@@ -87,6 +87,18 @@ class S3Storage:
|
|||||||
)
|
)
|
||||||
return response["Body"].read(), response.get("ContentType", "application/octet-stream")
|
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:
|
def _put(self, bucket: str, object_key: str, content: bytes, content_type: str) -> None:
|
||||||
if not self.configured:
|
if not self.configured:
|
||||||
raise RuntimeError("MinIO/S3 is not 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"]
|
||||||
Reference in New Issue
Block a user