feat: ship end-to-end interior design MVP
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
ArrowsOutIcon,
|
||||
BuildingsIcon,
|
||||
CheckIcon,
|
||||
CircleNotchIcon,
|
||||
CubeIcon,
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
DownloadSimpleIcon,
|
||||
GearSixIcon,
|
||||
ImageIcon,
|
||||
LockSimpleIcon,
|
||||
MagicWandIcon,
|
||||
PaperPlaneTiltIcon,
|
||||
PencilSimpleIcon,
|
||||
PlusIcon,
|
||||
SlidersHorizontalIcon,
|
||||
SparkleIcon,
|
||||
UploadSimpleIcon,
|
||||
WarningCircleIcon,
|
||||
@@ -24,493 +20,492 @@ import {
|
||||
import { ChangeEvent, FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { SettingsCenter } from "@/components/settings-center";
|
||||
import { styleDirections } from "@/lib/demo";
|
||||
import type { ChatMessage, PlanLayer, ProjectSnapshot, WorkflowStage } from "@/types/workflow";
|
||||
import type { DesignBrief, ProjectSnapshot, RenderAsset, WorkflowStageId } from "@/types/workflow";
|
||||
import type { SettingsReadiness } from "@/types/settings";
|
||||
|
||||
const iconWeight = "regular" as const;
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
|
||||
const stageMeta: Array<Pick<WorkflowStage, "id" | "label" | "detail">> = [
|
||||
{ id: "uploaded", label: "导入图纸", detail: "PDF 原文件" },
|
||||
{ id: "region_selection", label: "选择户型", detail: "候选区域" },
|
||||
{ id: "plan_review", label: "确认结构", detail: "墙、门窗与比例" },
|
||||
{ id: "blockout", label: "生成白模", detail: "空间骨架" },
|
||||
{ id: "style_brief", label: "明确风格", detail: "Style DNA" },
|
||||
{ id: "direction_selection", label: "选择方向", detail: "三个方案" },
|
||||
{ id: "render_review", label: "审阅效果", detail: "多视角" },
|
||||
{ id: "editing", label: "多轮修改", detail: "局部编辑" },
|
||||
|
||||
const EMPTY_BRIEF: DesignBrief = {
|
||||
residents: "",
|
||||
lifestyle: [],
|
||||
focus_rooms: [],
|
||||
preferred_styles: [],
|
||||
preferred_colors: [],
|
||||
disliked_elements: [],
|
||||
must_keep: [],
|
||||
budget_level: "适中",
|
||||
additional_notes: "",
|
||||
completed: false,
|
||||
};
|
||||
|
||||
const STAGES: Array<{ ids: Array<WorkflowStageId | "failed">; label: string; detail: string }> = [
|
||||
{ ids: ["uploaded", "region_selection"], label: "导入图纸", detail: "找到正确户型" },
|
||||
{ ids: ["plan_review", "blockout"], label: "理解空间", detail: "房间、动线、采光" },
|
||||
{ ids: ["style_brief"], label: "说清需求", detail: "生活方式与偏好" },
|
||||
{ ids: ["direction_selection"], label: "选择方向", detail: "三套 Style DNA" },
|
||||
{ ids: ["render_review"], label: "生成效果", detail: "真实模型出图" },
|
||||
{ ids: ["editing"], label: "多轮修改", detail: "保留版本继续改" },
|
||||
{ ids: ["completed"], label: "完成方案", detail: "查看与导出" },
|
||||
];
|
||||
|
||||
const LIFESTYLE_OPTIONS = ["经常下厨", "在家办公", "亲子活动", "朋友聚会", "重视收纳", "养宠物"];
|
||||
const STYLE_OPTIONS = ["现代简约", "原木自然", "中古现代", "侘寂", "现代法式", "轻工业"];
|
||||
const COLOR_OPTIONS = ["黑白灰", "自然木色", "低饱和彩色", "冷色调", "暖色调", "深色氛围"];
|
||||
|
||||
function stageIndex(stage?: WorkflowStageId | "failed") {
|
||||
if (!stage) return 0;
|
||||
const index = STAGES.findIndex((item) => item.ids.includes(stage));
|
||||
return index < 0 ? 0 : index;
|
||||
}
|
||||
|
||||
function PlanPreview({ project, selectable, onSelect }: {
|
||||
project: ProjectSnapshot;
|
||||
selectable?: boolean;
|
||||
onSelect?: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="product-plan-frame">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- local authenticated project preview. */}
|
||||
<img src={`${API_BASE}/v1/projects/${project.project_id}/preview`} alt={`${project.name} 户型图`} />
|
||||
{selectable ? (
|
||||
<div className="product-region-layer">
|
||||
{project.plan.regions.map((region, index) => {
|
||||
const [x0, y0, x1, y1] = region.bounds;
|
||||
return (
|
||||
<button
|
||||
key={region.id}
|
||||
type="button"
|
||||
className={region.recommended ? "recommended" : ""}
|
||||
style={{ left: `${x0 * 100}%`, top: `${y0 * 100}%`, width: `${(x1 - x0) * 100}%`, height: `${(y1 - y0) * 100}%` }}
|
||||
onClick={() => onSelect?.(region.id)}
|
||||
>
|
||||
<strong>候选 {index + 1}</strong>
|
||||
<span>{Math.round(region.confidence * 100)}%{region.recommended ? ",建议" : ""}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChoiceGroup({ label, options, values, onChange }: {
|
||||
label: string;
|
||||
options: string[];
|
||||
values: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="choice-group">
|
||||
<legend>{label}</legend>
|
||||
<div>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
className={values.includes(option) ? "selected" : ""}
|
||||
onClick={() => onChange(values.includes(option) ? values.filter((item) => item !== option) : [...values, option])}
|
||||
>
|
||||
{values.includes(option) ? <CheckIcon size={13} weight="bold" /> : null}{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowStudio() {
|
||||
const [project, setProject] = useState<ProjectSnapshot | null>(null);
|
||||
const [layers, setLayers] = useState<PlanLayer[]>([]);
|
||||
const [selectedDirection, setSelectedDirection] = useState("quiet-modern");
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [furnitureMode, setFurnitureMode] = useState<"reference" | "remove">("reference");
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [readiness, setReadiness] = useState<SettingsReadiness | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [workflowError, setWorkflowError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [brief, setBrief] = useState<DesignBrief>(EMPTY_BRIEF);
|
||||
const [grossArea, setGrossArea] = useState("");
|
||||
const [ceilingHeight, setCeilingHeight] = useState("2800");
|
||||
const [roomNames, setRoomNames] = useState("");
|
||||
const [renderRoom, setRenderRoom] = useState("客餐厅");
|
||||
const [renderView, setRenderView] = useState("入口看向客厅");
|
||||
const [activeRenderId, setActiveRenderId] = useState("");
|
||||
const [editInstruction, setEditInstruction] = useState("");
|
||||
const [chatDraft, setChatDraft] = useState("");
|
||||
const [chatReply, setChatReply] = useState("");
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const currentDirection = useMemo(
|
||||
() => styleDirections.find((item) => item.id === selectedDirection) ?? styleDirections[0],
|
||||
[selectedDirection],
|
||||
);
|
||||
|
||||
const workflowStages = useMemo<WorkflowStage[]>(() => {
|
||||
const activeIndex = project ? stageMeta.findIndex((item) => item.id === project.stage) : -1;
|
||||
return stageMeta.map((stage, index) => ({
|
||||
...stage,
|
||||
status: activeIndex < 0 ? "pending" : index < activeIndex ? "complete" : index === activeIndex ? "active" : "pending",
|
||||
}));
|
||||
}, [project]);
|
||||
|
||||
const selectedRegion = useMemo(
|
||||
() => project?.plan.regions.find((region) => region.id === project.plan.selected_region_id) ?? null,
|
||||
const activeStage = stageIndex(project?.stage);
|
||||
const selectedDirection = useMemo(
|
||||
() => project?.directions.find((item) => item.id === project.style.selected_direction_id) ?? null,
|
||||
[project],
|
||||
);
|
||||
const activeRender = useMemo<RenderAsset | null>(() => {
|
||||
if (!project?.renders.length) return null;
|
||||
return project.renders.find((item) => item.id === activeRenderId) ?? project.renders.at(-1) ?? null;
|
||||
}, [activeRenderId, project]);
|
||||
|
||||
function applyProject(next: ProjectSnapshot) {
|
||||
setProject(next);
|
||||
setLayers(next.plan.layers.map((layer) => ({ ...layer, count: layer.element_count ?? undefined })));
|
||||
setMessages([
|
||||
...next.plan.ingestion_notes.map((body, index) => ({
|
||||
id: `ingestion-${index}`,
|
||||
role: "assistant" as const,
|
||||
body,
|
||||
meta: "图纸解析",
|
||||
})),
|
||||
...next.plan.issues.filter((issue) => !issue.resolved).map((issue) => ({
|
||||
id: `issue-${issue.id}`,
|
||||
role: "assistant" as const,
|
||||
body: issue.message,
|
||||
meta: "等待确认",
|
||||
})),
|
||||
]);
|
||||
setBrief(next.brief ?? EMPTY_BRIEF);
|
||||
setGrossArea(next.plan.gross_area_sqm ? String(next.plan.gross_area_sqm) : "");
|
||||
setCeilingHeight(String(next.plan.ceiling_height_mm || 2800));
|
||||
setRoomNames(next.plan.structure?.rooms?.map((room) => room.name).join("、") ?? "");
|
||||
if (next.plan.structure?.rooms?.[0]?.name) setRenderRoom(next.plan.structure.rooms[0].name);
|
||||
if (next.renders.length) setActiveRenderId(next.renders[next.renders.length - 1].id);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function loadReadiness() {
|
||||
async function boot() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/v1/readiness`, { cache: "no-store" });
|
||||
if (!response.ok) return;
|
||||
const current = await response.json() as SettingsReadiness;
|
||||
setReadiness(current);
|
||||
if (!current.ready && !window.sessionStorage.getItem("settings-intro-seen")) {
|
||||
window.sessionStorage.setItem("settings-intro-seen", "true");
|
||||
setSettingsOpen(true);
|
||||
const [readinessResponse, projectsResponse] = await Promise.all([
|
||||
fetch(`${API_BASE}/v1/readiness`, { cache: "no-store" }),
|
||||
fetch(`${API_BASE}/v1/projects`, { cache: "no-store" }),
|
||||
]);
|
||||
if (readinessResponse.ok) {
|
||||
const current = await readinessResponse.json() as SettingsReadiness;
|
||||
setReadiness(current);
|
||||
if (!current.ready) setSettingsOpen(true);
|
||||
}
|
||||
if (projectsResponse.ok) {
|
||||
const projects = await projectsResponse.json() as ProjectSnapshot[];
|
||||
const latest = projects.find((item) => item.project_id !== "demo-apartment");
|
||||
if (latest) applyProject(latest);
|
||||
}
|
||||
} catch {
|
||||
setReadiness(null);
|
||||
setError("无法连接本地服务,请检查 Docker 是否正在运行。");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
void loadReadiness();
|
||||
fetch(`${API_BASE}/v1/projects`, { cache: "no-store" })
|
||||
.then((response) => response.ok ? response.json() as Promise<ProjectSnapshot[]> : [])
|
||||
.then((projects) => {
|
||||
const latest = projects.find((item) => item.project_id !== "demo-apartment");
|
||||
if (latest) applyProject(latest);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
void boot();
|
||||
}, []);
|
||||
|
||||
async function readApiError(response: Response, fallback: string) {
|
||||
async function apiError(response: Response, fallback: string) {
|
||||
try {
|
||||
const payload = await response.json() as { detail?: string | { message?: string } };
|
||||
if (typeof payload.detail === "string") return payload.detail;
|
||||
if (payload.detail?.message) return payload.detail.message;
|
||||
} catch {
|
||||
// Keep the readable fallback.
|
||||
}
|
||||
const body = await response.json() as { detail?: string | { message?: string } };
|
||||
if (typeof body.detail === "string") return body.detail;
|
||||
if (body.detail?.message) return body.detail.message;
|
||||
} catch { /* use fallback */ }
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function uploadPlan(file: File) {
|
||||
if (!readiness?.ready) {
|
||||
setSettingsOpen(true);
|
||||
setWorkflowError("请先完成系统设置和真实生图验证。");
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
setWorkflowError(null);
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("project_name", `${file.name.replace(/\.pdf$/i, "")} 概念方案`);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/v1/projects/ingest`, { method: "POST", body: form });
|
||||
if (!response.ok) throw new Error(await readApiError(response, "图纸上传或解析失败。"));
|
||||
applyProject(await response.json() as ProjectSnapshot);
|
||||
} catch (error) {
|
||||
setWorkflowError(error instanceof Error ? error.message : "图纸上传或解析失败。");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function chooseFile(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) void uploadPlan(file);
|
||||
}
|
||||
|
||||
async function selectRegion(regionId: string) {
|
||||
if (!project || project.stage !== "region_selection") return;
|
||||
setWorkflowError(null);
|
||||
const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/commands`, {
|
||||
async function command(base: ProjectSnapshot, name: string, payload: Record<string, unknown> = {}) {
|
||||
const response = await fetch(`${API_BASE}/v1/projects/${base.project_id}/commands`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
command: "select_region",
|
||||
expected_revision: project.revision,
|
||||
payload: { region_id: regionId },
|
||||
}),
|
||||
body: JSON.stringify({ command: name, expected_revision: base.revision, payload }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
setWorkflowError(await readApiError(response, "候选户型选择失败。"));
|
||||
if (!response.ok) throw new Error(await apiError(response, "工作流操作失败。"));
|
||||
return response.json() as Promise<ProjectSnapshot>;
|
||||
}
|
||||
|
||||
async function upload(file: File) {
|
||||
if (!readiness?.ready) {
|
||||
setSettingsOpen(true);
|
||||
return;
|
||||
}
|
||||
applyProject(await response.json() as ProjectSnapshot);
|
||||
setBusy("正在读取 PDF 并寻找户型区域");
|
||||
setError("");
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("project_name", `${file.name.replace(/\.pdf$/i, "")} 风格方案`);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/v1/projects/ingest`, { method: "POST", body: form });
|
||||
if (!response.ok) throw new Error(await apiError(response, "图纸上传失败。"));
|
||||
applyProject(await response.json() as ProjectSnapshot);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "图纸上传失败。");
|
||||
} finally {
|
||||
setBusy("");
|
||||
if (fileInput.current) fileInput.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLayer(id: string) {
|
||||
setLayers((items) =>
|
||||
items.map((item) => (item.id === id ? { ...item, visible: !item.visible } : item)),
|
||||
);
|
||||
function onFile(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) void upload(file);
|
||||
}
|
||||
|
||||
function sendMessage(event: FormEvent<HTMLFormElement>) {
|
||||
async function chooseRegion(id: string) {
|
||||
if (!project) return;
|
||||
setBusy("正在建立目标户型");
|
||||
setError("");
|
||||
try { applyProject(await command(project, "select_region", { region_id: id })); }
|
||||
catch (reason) { setError(reason instanceof Error ? reason.message : "选择失败。"); }
|
||||
finally { setBusy(""); }
|
||||
}
|
||||
|
||||
async function analyzePlan() {
|
||||
if (!project) return;
|
||||
setBusy("空间模型正在识别房间、动线与采光");
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/spatial-analysis`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ gross_area_sqm: grossArea ? Number(grossArea) : null }),
|
||||
});
|
||||
if (!response.ok) throw new Error(await apiError(response, "空间分析失败。"));
|
||||
applyProject(await response.json() as ProjectSnapshot);
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : "空间分析失败。"); }
|
||||
finally { setBusy(""); }
|
||||
}
|
||||
|
||||
async function confirmStructure() {
|
||||
if (!project) return;
|
||||
setBusy("正在建立空间骨架和推荐视角");
|
||||
setError("");
|
||||
try {
|
||||
const confirmed = project.stage === "blockout"
|
||||
? project
|
||||
: await command(project, "confirm_plan", {
|
||||
gross_area_sqm: grossArea ? Number(grossArea) : null,
|
||||
ceiling_height_mm: Number(ceilingHeight) || 2800,
|
||||
room_names: roomNames.split(/[、,,\n]/).map((item) => item.trim()).filter(Boolean),
|
||||
});
|
||||
applyProject(await command(confirmed, "build_blockout"));
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : "结构确认失败。"); }
|
||||
finally { setBusy(""); }
|
||||
}
|
||||
|
||||
async function createDirections() {
|
||||
if (!project) return;
|
||||
setBusy("设计总监正在生成三套 Style DNA");
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/style-directions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ brief }),
|
||||
});
|
||||
if (!response.ok) throw new Error(await apiError(response, "风格方向生成失败。"));
|
||||
applyProject(await response.json() as ProjectSnapshot);
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : "风格方向生成失败。"); }
|
||||
finally { setBusy(""); }
|
||||
}
|
||||
|
||||
async function selectDirection(id: string) {
|
||||
if (!project) return;
|
||||
setBusy("正在锁定设计方向");
|
||||
setError("");
|
||||
try { applyProject(await command(project, "select_direction", { direction_id: id })); }
|
||||
catch (reason) { setError(reason instanceof Error ? reason.message : "方向选择失败。"); }
|
||||
finally { setBusy(""); }
|
||||
}
|
||||
|
||||
async function generateRender() {
|
||||
if (!project?.style.selected_direction_id) return;
|
||||
setBusy("真实生图模型正在生成效果图,通常需要 20 秒到 2 分钟");
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/renders`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ direction_id: project.style.selected_direction_id, room: renderRoom, view: renderView }),
|
||||
});
|
||||
if (!response.ok) throw new Error(await apiError(response, "效果图生成失败。"));
|
||||
applyProject(await response.json() as ProjectSnapshot);
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : "效果图生成失败。"); }
|
||||
finally { setBusy(""); }
|
||||
}
|
||||
|
||||
async function editRender(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const value = draft.trim();
|
||||
if (!value) return;
|
||||
setMessages((items) => [
|
||||
...items,
|
||||
{ id: `user-${Date.now()}`, role: "user", body: value },
|
||||
{
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: "assistant",
|
||||
body: "已记录为设计约束。正式接入后,这条指令会先更新布局锁定和 Style DNA,再只重绘受影响视角。",
|
||||
meta: "结构化修改",
|
||||
},
|
||||
]);
|
||||
setDraft("");
|
||||
if (!project || !activeRender || !editInstruction.trim()) return;
|
||||
setBusy("正在只修改指定内容,并保持其他区域不变");
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/renders/${activeRender.id}/edit`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ instruction: editInstruction.trim() }),
|
||||
});
|
||||
if (!response.ok) throw new Error(await apiError(response, "效果图修改失败。"));
|
||||
applyProject(await response.json() as ProjectSnapshot);
|
||||
setEditInstruction("");
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : "效果图修改失败。"); }
|
||||
finally { setBusy(""); }
|
||||
}
|
||||
|
||||
async function chat(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!project || !chatDraft.trim()) return;
|
||||
const message = chatDraft.trim();
|
||||
setChatDraft("");
|
||||
setBusy("设计 Agent 正在理解你的要求");
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
if (!response.ok) throw new Error(await apiError(response, "设计 Agent 暂时无法回复。"));
|
||||
const result = await response.json() as { project: ProjectSnapshot; reply: string };
|
||||
applyProject(result.project);
|
||||
setChatReply(result.reply);
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : "设计 Agent 暂时无法回复。"); }
|
||||
finally { setBusy(""); }
|
||||
}
|
||||
|
||||
async function completeProject() {
|
||||
if (!project) return;
|
||||
setBusy("正在整理最终方案");
|
||||
try { applyProject(await command(project, "complete_project")); }
|
||||
catch (reason) { setError(reason instanceof Error ? reason.message : "方案整理失败。"); }
|
||||
finally { setBusy(""); }
|
||||
}
|
||||
|
||||
function downloadSummary() {
|
||||
if (!project) return;
|
||||
const payload = JSON.stringify(project, null, 2);
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(new Blob([payload], { type: "application/json" }));
|
||||
link.download = `${project.name}-方案记录.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
|
||||
function renderWorkspace() {
|
||||
if (loading) return <div className="product-empty"><CircleNotchIcon className="spin" size={28} /><h2>正在打开工作台</h2></div>;
|
||||
if (!project) return (
|
||||
<div className="product-empty product-onboarding">
|
||||
<div className="onboarding-art"><BuildingsIcon size={58} weight="thin" /></div>
|
||||
<p className="product-kicker">从真实户型开始</p>
|
||||
<h1>把一张平面图,变成可反复修改的风格方案</h1>
|
||||
<p>上传 PDF 后,系统会带你完成空间理解、风格选择、真实生图和多轮修改。</p>
|
||||
<button className="product-button primary" type="button" onClick={() => readiness?.ready ? fileInput.current?.click() : setSettingsOpen(true)}>
|
||||
<UploadSimpleIcon size={17} />{readiness?.ready ? "选择 PDF 户型图" : "先完成系统设置"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (project.stage === "region_selection") return (
|
||||
<section className="product-stage">
|
||||
<header className="stage-header"><div><p>先选对户型</p><h1>这页图纸里,哪一块是你要设计的家?</h1></div><span>点击图中的候选框</span></header>
|
||||
<div className="region-stage"><PlanPreview project={project} selectable onSelect={(id) => void chooseRegion(id)} /><aside><h2>识别结果</h2><strong>{project.plan.regions.length} 个候选区域</strong><p>{project.plan.vector_based ? `矢量 PDF,共读取 ${project.plan.vector_element_count.toLocaleString()} 个图形元素。` : "扫描型图纸,已使用视觉区域检测。"}</p><p>选择只会裁剪工作区域,不会修改原 PDF。</p></aside></div>
|
||||
</section>
|
||||
);
|
||||
|
||||
if (project.stage === "plan_review" || project.stage === "blockout") {
|
||||
const structure = project.plan.structure;
|
||||
return (
|
||||
<section className="product-stage">
|
||||
<header className="stage-header"><div><p>空间理解</p><h1>先确认布局事实,再谈风格</h1></div><span>{structure.status === "pending" ? "等待 AI 读图" : structure.degraded ? "可编辑草案" : `由 ${structure.model_name} 识别`}</span></header>
|
||||
<div className="structure-layout">
|
||||
<PlanPreview project={project} />
|
||||
<div className="structure-review">
|
||||
{structure.status === "pending" ? (
|
||||
<div className="analysis-callout"><MagicWandIcon size={28} /><h2>让空间模型读懂这套户型</h2><p>它会判断房间、主要动线、采光和布局机会。结果可以在下一步人工修正。</p><label>建筑面积(可选)<input value={grossArea} onChange={(event) => setGrossArea(event.target.value)} inputMode="decimal" placeholder="例如 108" /></label><button className="product-button primary" type="button" onClick={() => void (project.stage === "blockout" ? confirmStructure() : analyzePlan())}>{project.stage === "blockout" ? "继续进入风格需求" : "开始空间分析"}</button></div>
|
||||
) : (
|
||||
<>
|
||||
<div className={`analysis-summary ${structure.degraded ? "warning" : ""}`}><strong>{structure.summary}</strong><p>{structure.degraded ? "模型失败并不阻塞流程,请直接修改下面的房间名称。" : structure.circulation}</p></div>
|
||||
<label className="product-field">房间清单<input value={roomNames} onChange={(event) => setRoomNames(event.target.value)} placeholder="客厅、餐厅、主卧、次卧、厨房、卫生间" /><small>用顿号或逗号分隔,可直接修正 AI 判断</small></label>
|
||||
<div className="two-fields"><label className="product-field">建筑面积(㎡)<input value={grossArea} onChange={(event) => setGrossArea(event.target.value)} inputMode="decimal" /></label><label className="product-field">层高(mm)<input value={ceilingHeight} onChange={(event) => setCeilingHeight(event.target.value)} inputMode="numeric" /></label></div>
|
||||
<div className="insight-grid"><div><h3>布局机会</h3>{structure.layout_opportunities.map((item) => <p key={item}><CheckIcon size={13} />{item}</p>)}</div><div><h3>需要留意</h3>{structure.risks.map((item) => <p key={item}><WarningCircleIcon size={13} />{item}</p>)}</div></div>
|
||||
<button className="product-button primary wide" type="button" onClick={() => void confirmStructure()}>确认结构,进入风格需求 <ArrowRightIcon size={16} /></button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (project.stage === "style_brief") return (
|
||||
<section className="product-stage brief-stage">
|
||||
<header className="stage-header"><div><p>风格需求</p><h1>不需要懂专业术语,说清你怎么生活就够了</h1></div><span>约 2 分钟</span></header>
|
||||
<div className="brief-form">
|
||||
<label className="product-field wide-field">谁会住在这里?<input value={brief.residents} onChange={(event) => setBrief({ ...brief, residents: event.target.value })} placeholder="例如:两位大人、一个 6 岁孩子和一只猫" /></label>
|
||||
<ChoiceGroup label="平时最重要的生活场景" options={LIFESTYLE_OPTIONS} values={brief.lifestyle} onChange={(lifestyle) => setBrief({ ...brief, lifestyle })} />
|
||||
<ChoiceGroup label="更接近你的风格" options={STYLE_OPTIONS} values={brief.preferred_styles} onChange={(preferred_styles) => setBrief({ ...brief, preferred_styles })} />
|
||||
<ChoiceGroup label="喜欢的色彩倾向" options={COLOR_OPTIONS} values={brief.preferred_colors} onChange={(preferred_colors) => setBrief({ ...brief, preferred_colors })} />
|
||||
<div className="brief-grid"><label className="product-field">重点设计空间<input value={brief.focus_rooms.join("、")} onChange={(event) => setBrief({ ...brief, focus_rooms: event.target.value.split(/[、,,]/).filter(Boolean) })} placeholder="客餐厅、主卧" /></label><label className="product-field">预算倾向<select value={brief.budget_level} onChange={(event) => setBrief({ ...brief, budget_level: event.target.value })}><option>尽量节制</option><option>适中</option><option>品质优先</option></select></label></div>
|
||||
<div className="brief-grid"><label className="product-field">明确不喜欢<input value={brief.disliked_elements.join("、")} onChange={(event) => setBrief({ ...brief, disliked_elements: event.target.value.split(/[、,,]/).filter(Boolean) })} placeholder="例如:亮面石材、复杂吊顶" /></label><label className="product-field">必须保留<input value={brief.must_keep.join("、")} onChange={(event) => setBrief({ ...brief, must_keep: event.target.value.split(/[、,,]/).filter(Boolean) })} placeholder="例如:旧餐桌、钢琴" /></label></div>
|
||||
<label className="product-field wide-field">还有什么想告诉设计师?<textarea value={brief.additional_notes} onChange={(event) => setBrief({ ...brief, additional_notes: event.target.value })} placeholder="例如:希望客厅看起来更松弛,但不要像民宿" /></label>
|
||||
<button className="product-button primary form-submit" type="button" onClick={() => void createDirections()}>生成三套风格方向 <SparkleIcon size={16} weight="fill" /></button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
if (project.stage === "direction_selection") return (
|
||||
<section className="product-stage directions-stage">
|
||||
<header className="stage-header"><div><p>设计方向</p><h1>三套都能落地,但气质不同</h1></div><span>选择后仍可返回修改需求</span></header>
|
||||
<div className="direction-grid">
|
||||
{project.directions.map((direction) => (
|
||||
<article className="direction-card" key={direction.id}>
|
||||
<div className="palette-strip">{direction.palette.map((color) => <span key={color.hex} style={{ background: color.hex }} title={`${color.name},${color.role}`} />)}</div>
|
||||
<div className="direction-body"><h2>{direction.name}</h2><p>{direction.thesis}</p><div className="keyword-row">{direction.keywords.map((item) => <span key={item}>{item}</span>)}</div><dl><dt>材料</dt><dd>{direction.materials.join("、")}</dd><dt>光线</dt><dd>{direction.lighting}</dd></dl><small>{direction.model_name || "设计策略"}</small><button className="product-button primary wide" type="button" onClick={() => void selectDirection(direction.id)}>用这个方向生成效果图</button></div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
if (project.stage === "render_review") return (
|
||||
<section className="product-stage render-setup">
|
||||
<header className="stage-header"><div><p>效果图生成</p><h1>先生成一个关键视角,再决定是否扩展</h1></div><span>将调用真实生图模型</span></header>
|
||||
<div className="render-setup-grid">
|
||||
<div className="selected-direction-panel"><div className="palette-strip">{selectedDirection?.palette.map((color) => <span key={color.hex} style={{ background: color.hex }} />)}</div><h2>{selectedDirection?.name}</h2><p>{selectedDirection?.thesis}</p><strong>{selectedDirection?.materials.join("、")}</strong></div>
|
||||
<div className="render-controls"><label className="product-field">生成哪个空间?<select value={renderRoom} onChange={(event) => setRenderRoom(event.target.value)}>{project.plan.structure.rooms.map((room) => <option key={room.id}>{room.name}</option>)}<option>客餐厅</option><option>主卧</option></select></label><label className="product-field">相机视角<select value={renderView} onChange={(event) => setRenderView(event.target.value)}><option>入口看向客厅</option><option>客厅看向餐厅</option><option>窗边看向室内</option><option>空间广角主视角</option></select></label><div className="generation-note"><CubeIcon size={20} /><p>系统会把户型裁剪图、空间理解和 Style DNA 一起交给当前模型池中的生图模型。</p></div><button className="product-button primary wide" type="button" onClick={() => void generateRender()}><ImageIcon size={17} />生成真实效果图</button></div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
if (project.stage === "editing" || project.stage === "completed") return (
|
||||
<section className="product-stage editing-stage">
|
||||
<header className="stage-header"><div><p>{project.stage === "completed" ? "方案已完成" : "效果图与修改"}</p><h1>{activeRender?.room},{activeRender?.view}</h1></div><span>{project.renders.length} 个版本</span></header>
|
||||
{activeRender ? (
|
||||
<div className="render-workspace">
|
||||
<div className="render-main">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- generated project asset. */}
|
||||
<img src={`${API_BASE}/v1/projects/${project.project_id}/renders/${activeRender.id}`} alt={`${activeRender.room} ${activeRender.view} 效果图`} />
|
||||
<div className="render-meta"><span>{activeRender.model_name}</span><span>{new Date(activeRender.created_at).toLocaleString("zh-CN")}</span></div>
|
||||
</div>
|
||||
<aside className="render-sidebar"><h2>版本</h2><div className="version-list">{[...project.renders].reverse().map((render, index) => <button className={render.id === activeRender.id ? "active" : ""} type="button" key={render.id} onClick={() => setActiveRenderId(render.id)}><ImageIcon size={16} /><span><strong>版本 {project.renders.length - index}</strong><small>{render.edit_instruction || `${render.room},${render.view}`}</small></span></button>)}</div>{project.stage === "editing" ? <form className="edit-form" onSubmit={editRender}><label>只修改什么?<textarea value={editInstruction} onChange={(event) => setEditInstruction(event.target.value)} placeholder="例如:把沙发改为更低矮的米灰色模块沙发,其他都不变" /></label><button className="product-button primary wide" type="submit" disabled={!editInstruction.trim()}><PencilSimpleIcon size={16} />生成新版本</button><button className="product-button secondary wide" type="button" onClick={() => void completeProject()}>确认当前方案</button></form> : <button className="product-button primary wide" type="button" onClick={downloadSummary}><DownloadSimpleIcon size={16} />导出方案记录</button>}</aside>
|
||||
</div>
|
||||
) : <div className="product-empty"><ImageIcon size={36} /><h2>还没有效果图</h2></div>}
|
||||
</section>
|
||||
);
|
||||
|
||||
return <div className="product-empty"><CubeIcon size={34} /><h2>正在准备下一阶段</h2></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={250}>
|
||||
<main className="studio-shell">
|
||||
<header className="topbar">
|
||||
<div className="brand-block">
|
||||
<div className="brand-mark" aria-hidden="true">
|
||||
<BuildingsIcon size={19} weight="fill" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="brand-name">空间风格工作台</p>
|
||||
<p className="brand-subtitle">{project?.name ?? "新建住宅概念方案"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="topbar-actions">
|
||||
<span className="save-state"><CheckIcon size={14} weight="bold" /> 已保存</span>
|
||||
<button className="button button-secondary settings-button" type="button" onClick={() => setSettingsOpen(true)}>
|
||||
<GearSixIcon size={16} /> 系统设置
|
||||
{readiness && !readiness.ready ? <span className="settings-alert-dot" aria-label="有必备配置未完成" /> : null}
|
||||
</button>
|
||||
<button className="button button-secondary" type="button">版本记录</button>
|
||||
<button
|
||||
className="button button-primary"
|
||||
type="button"
|
||||
disabled={uploading || Boolean(project)}
|
||||
onClick={() => readiness?.ready ? fileInputRef.current?.click() : setSettingsOpen(true)}
|
||||
title={!readiness?.ready ? "请先完成系统设置" : project ? "当前阶段请在图纸上完成候选区域确认" : undefined}
|
||||
>
|
||||
{uploading ? "正在解析" : project ? "按图确认" : readiness?.ready ? "导入图纸" : "配置后开始"} <ArrowRightIcon size={16} weight="bold" />
|
||||
</button>
|
||||
<input ref={fileInputRef} className="visually-hidden" type="file" accept="application/pdf,.pdf" onChange={chooseFile} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="studio-grid">
|
||||
<aside className="workflow-rail" aria-label="设计流程">
|
||||
<div className="rail-heading">
|
||||
<p>设计流程</p>
|
||||
<span>{project ? Math.max(1, stageMeta.findIndex((item) => item.id === project.stage) + 1) : 0} / 8</span>
|
||||
</div>
|
||||
<nav className="stage-list">
|
||||
{workflowStages.map((stage) => (
|
||||
<button
|
||||
className={`stage-item stage-${stage.status}`}
|
||||
key={stage.id}
|
||||
type="button"
|
||||
aria-current={stage.status === "active" ? "step" : undefined}
|
||||
>
|
||||
<span className="stage-index" aria-hidden="true">
|
||||
{stage.status === "complete" ? <CheckIcon size={13} weight="bold" /> : null}
|
||||
</span>
|
||||
<span className="stage-copy">
|
||||
<strong>{stage.label}</strong>
|
||||
<small>{stage.detail}</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="rail-note">
|
||||
<WarningCircleIcon size={18} weight={iconWeight} />
|
||||
<p>{project
|
||||
? project.plan.issues.filter((issue) => !issue.resolved).length > 0
|
||||
? `还有 ${project.plan.issues.filter((issue) => !issue.resolved).length} 项图纸问题需要确认。`
|
||||
: "当前阶段的信息已确认。"
|
||||
: "请先导入一份 PDF 户型图。"}</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="workspace">
|
||||
<Tabs.Root defaultValue="plan" className="workspace-tabs">
|
||||
<div className="workspace-toolbar">
|
||||
<Tabs.List className="tab-list" aria-label="工作区视图">
|
||||
<Tabs.Trigger className="tab-trigger" value="plan">
|
||||
<SlidersHorizontalIcon size={16} /> 户型清洗
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger className="tab-trigger" value="blockout">
|
||||
<CubeIcon size={16} /> 3D 白模
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger className="tab-trigger" value="renders">
|
||||
<ImageIcon size={16} /> 效果图
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<div className="canvas-actions">
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button className="icon-button" type="button" aria-label="适应画布">
|
||||
<ArrowsOutIcon size={18} />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal><Tooltip.Content className="tooltip">适应画布</Tooltip.Content></Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
<button className="button button-secondary compact" type="button" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
|
||||
<UploadSimpleIcon size={16} /> {project ? "更换图纸" : "导入图纸"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs.Content value="plan" className="tab-content">
|
||||
{!project ? (
|
||||
<div className="plan-upload-empty">
|
||||
<div className="upload-emblem"><UploadSimpleIcon size={30} /></div>
|
||||
<h2>{uploading ? "正在读取和分析图纸" : "导入第一份真实户型图"}</h2>
|
||||
<p>当前支持 25 MB 以内的 PDF。系统会保留原文件、判断矢量或扫描类型,并检测同页中的多个候选平面区域。</p>
|
||||
<button className="button button-primary" type="button" onClick={() => fileInputRef.current?.click()} disabled={uploading || !readiness?.ready}>
|
||||
{uploading ? <CircleNotchIcon className="spin" size={16} /> : <UploadSimpleIcon size={16} />}
|
||||
{uploading ? "解析中,请稍候" : readiness?.ready ? "选择 PDF 图纸" : "完成设置后导入"}
|
||||
</button>
|
||||
{workflowError ? <p className="workflow-error"><WarningCircleIcon size={16} /> {workflowError}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="plan-workspace">
|
||||
<div className="plan-canvas">
|
||||
<div className="canvas-labels">
|
||||
<span>{selectedRegion?.name ?? `${project.plan.regions.length} 个候选平面区域`}</span>
|
||||
<span>{project.plan.vector_based ? "矢量 PDF" : "扫描 PDF"} · {project.plan.vector_element_count.toLocaleString()} 个矢量元素</span>
|
||||
</div>
|
||||
<div className="plan-document-frame">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- authenticated runtime preview URL. */}
|
||||
<img
|
||||
src={`${API_BASE}/v1/projects/${project.project_id}/preview`}
|
||||
alt={`${project.name} 的 PDF 首页预览`}
|
||||
className="plan-document-image"
|
||||
/>
|
||||
<div className="region-layer" aria-label="候选户型区域">
|
||||
{project.plan.regions.map((region, index) => {
|
||||
const [x0, y0, x1, y1] = region.bounds;
|
||||
const selected = region.id === project.plan.selected_region_id;
|
||||
return (
|
||||
<button
|
||||
className={`detected-region ${selected ? "selected" : ""} ${region.recommended ? "recommended" : ""}`}
|
||||
key={region.id}
|
||||
type="button"
|
||||
style={{ left: `${x0 * 100}%`, top: `${y0 * 100}%`, width: `${(x1 - x0) * 100}%`, height: `${(y1 - y0) * 100}%` }}
|
||||
onClick={() => void selectRegion(region.id)}
|
||||
disabled={project.stage !== "region_selection"}
|
||||
aria-label={`选择${region.name}`}
|
||||
>
|
||||
<span>{index + 1}</span>
|
||||
<strong>{region.name}</strong>
|
||||
<small>{region.recommended ? "建议选择 · " : ""}{Math.round(region.confidence * 100)}%</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="canvas-status">
|
||||
<span><CheckIcon size={14} weight="bold" /> 原图与解析预览已保存至 MinIO</span>
|
||||
<span>{project.stage === "region_selection" ? "请选择目标户型" : "目标区域已确认"}</span>
|
||||
</div>
|
||||
{workflowError ? <p className="workflow-error"><WarningCircleIcon size={16} /> {workflowError}</p> : null}
|
||||
</div>
|
||||
|
||||
<aside className="layer-panel">
|
||||
<div className="panel-title-row">
|
||||
<div>
|
||||
<p className="panel-title">解析内容</p>
|
||||
<p className="panel-caption">PDF 首页 · {project.plan.page_count} 页文件</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="layer-list">
|
||||
{layers.map((layer) => (
|
||||
<button
|
||||
className={`layer-row ${layer.visible ? "is-visible" : ""}`}
|
||||
key={layer.id}
|
||||
type="button"
|
||||
onClick={() => toggleLayer(layer.id)}
|
||||
>
|
||||
{layer.visible ? <EyeIcon size={17} /> : <EyeSlashIcon size={17} />}
|
||||
<span>{layer.label}</span>
|
||||
{layer.count ? <small>{layer.count.toLocaleString()}</small> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="panel-divider" />
|
||||
<p className="panel-title">识别状态</p>
|
||||
<div className="ingestion-facts">
|
||||
<span><CheckIcon size={13} /> {project.plan.vector_based ? "矢量信息可用" : "需要视觉分割"}</span>
|
||||
<span><CheckIcon size={13} /> {project.plan.ocr_used ? "百度 OCR 已参与" : "PDF 文字层可读取"}</span>
|
||||
<span><WarningCircleIcon size={13} /> 比例尚待交叉确认</span>
|
||||
</div>
|
||||
|
||||
<div className="panel-divider" />
|
||||
<p className="panel-title">原家具处理</p>
|
||||
<div className="segmented-control">
|
||||
<button type="button" className={furnitureMode === "reference" ? "active" : ""} onClick={() => setFurnitureMode("reference")}>保留参考</button>
|
||||
<button type="button" className={furnitureMode === "remove" ? "active" : ""} onClick={() => setFurnitureMode("remove")}>仅看结构</button>
|
||||
</div>
|
||||
<p className="helper-text">候选区域确认后,下一阶段会分离墙、门窗、文字与家具意图。</p>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="blockout" className="tab-content empty-workspace">
|
||||
<CubeIcon size={38} weight="thin" />
|
||||
<h2>白模将在结构确认后生成</h2>
|
||||
<p>系统会拉伸墙体、门窗和柱,并自动建立三个室内相机。</p>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="renders" className="tab-content empty-workspace">
|
||||
<ImageIcon size={38} weight="thin" />
|
||||
<h2>先选定风格方向</h2>
|
||||
<p>低成本方向图通过后,再生成完整多视角效果图。</p>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
|
||||
<section className="conversation" aria-label="设计对话">
|
||||
<div className="message-stream">
|
||||
{!project ? (
|
||||
<article className="message message-assistant">
|
||||
<span className="assistant-icon"><SparkleIcon size={15} weight="fill" /></span>
|
||||
<div><small>等待图纸</small><p>导入 PDF 后,我会先判断图纸类型并请你选择目标户型区域。</p></div>
|
||||
</article>
|
||||
) : null}
|
||||
{messages.slice(-3).map((message) => (
|
||||
<article className={`message message-${message.role}`} key={message.id}>
|
||||
{message.role === "assistant" ? (
|
||||
<span className="assistant-icon"><SparkleIcon size={15} weight="fill" /></span>
|
||||
) : null}
|
||||
<div>
|
||||
{message.meta ? <small>{message.meta}</small> : null}
|
||||
<p>{message.body}</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<form className="composer" onSubmit={sendMessage}>
|
||||
<label htmlFor="design-instruction">告诉我需要保留什么,或者希望空间给人怎样的感受</label>
|
||||
<div className="composer-row">
|
||||
<input
|
||||
id="design-instruction"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
placeholder={project ? "例如:保留床和窗的位置,客厅更松弛,不要冷灰" : "导入图纸后开始对话"}
|
||||
disabled={!project}
|
||||
/>
|
||||
<button className="send-button" type="submit" aria-label="发送设计指令" disabled={!project}>
|
||||
<PaperPlaneTiltIcon size={18} weight="fill" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside className="style-inspector">
|
||||
<div className="inspector-heading">
|
||||
<div>
|
||||
<p className="panel-title">Style DNA</p>
|
||||
<p className="panel-caption">跨视角设计约束</p>
|
||||
</div>
|
||||
<span className="lock-label"><LockSimpleIcon size={13} /> 2 项锁定</span>
|
||||
</div>
|
||||
|
||||
<div className="style-section">
|
||||
<label htmlFor="style-concept">设计概念</label>
|
||||
<textarea key={project?.project_id ?? "empty"} id="style-concept" defaultValue={project?.style.concept ?? "导入图纸后建立 Style DNA"} disabled={!project} />
|
||||
</div>
|
||||
|
||||
<div className="style-section">
|
||||
<p className="style-label">方向草案</p>
|
||||
<div className="direction-list">
|
||||
{styleDirections.map((direction) => (
|
||||
<button
|
||||
type="button"
|
||||
key={direction.id}
|
||||
className={`direction-option ${selectedDirection === direction.id ? "selected" : ""}`}
|
||||
onClick={() => setSelectedDirection(direction.id)}
|
||||
>
|
||||
<span className="direction-colors" aria-hidden="true">
|
||||
{direction.colors.map((color) => (
|
||||
<span key={color} style={{ backgroundColor: color }} />
|
||||
))}
|
||||
</span>
|
||||
<span>
|
||||
<strong>{direction.name}</strong>
|
||||
<small>{direction.summary}</small>
|
||||
</span>
|
||||
{selectedDirection === direction.id ? <CheckIcon size={15} weight="bold" /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="style-section material-section">
|
||||
<p className="style-label">材料语言</p>
|
||||
<div className="tag-list">
|
||||
{currentDirection.materials.map((material) => <span key={material}>{material}</span>)}
|
||||
<button type="button"><PlusIcon size={13} /> 添加</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="style-section constraint-section">
|
||||
<p className="style-label">明确避免</p>
|
||||
<p>大面积冷灰、高亮岩板、过量灯带</p>
|
||||
</div>
|
||||
|
||||
<div className="inspector-footer">
|
||||
<div>
|
||||
<CircleNotchIcon size={17} />
|
||||
<span>等待结构确认</span>
|
||||
</div>
|
||||
<button className="button button-primary full" type="button">保存风格草案</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="product-shell">
|
||||
<header className="product-topbar">
|
||||
<div className="product-brand"><span><BuildingsIcon size={19} weight="fill" /></span><div><strong>住境 AI</strong><small>{project?.name ?? "住宅风格设计工作台"}</small></div></div>
|
||||
<div className="product-top-actions">
|
||||
{project ? <button className="product-button ghost" type="button" onClick={() => setProject(null)}><PlusIcon size={15} />新方案</button> : null}
|
||||
<button className="product-button secondary" type="button" onClick={() => setSettingsOpen(true)}><GearSixIcon size={16} />系统设置</button>
|
||||
</div>
|
||||
<SettingsCenter
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onReadinessChange={setReadiness}
|
||||
/>
|
||||
</main>
|
||||
</Tooltip.Provider>
|
||||
<input ref={fileInput} className="visually-hidden" type="file" accept="application/pdf,.pdf" onChange={onFile} />
|
||||
</header>
|
||||
|
||||
<div className="product-body">
|
||||
<aside className="product-rail">
|
||||
<div className="rail-progress"><strong>{project ? activeStage + 1 : 0}</strong><span>/ {STAGES.length}</span></div>
|
||||
<nav>{STAGES.map((stage, index) => <div key={stage.label} className={index < activeStage ? "complete" : index === activeStage && project ? "active" : "pending"}><span>{index < activeStage ? <CheckIcon size={12} weight="bold" /> : index + 1}</span><p><strong>{stage.label}</strong><small>{stage.detail}</small></p></div>)}</nav>
|
||||
{project ? <button className="change-plan" type="button" onClick={() => fileInput.current?.click()}><UploadSimpleIcon size={15} />更换图纸</button> : null}
|
||||
</aside>
|
||||
|
||||
<div className="product-center">
|
||||
{busy ? <div className="busy-banner"><CircleNotchIcon className="spin" size={16} /><span>{busy}</span></div> : null}
|
||||
{error ? <div className="error-banner"><WarningCircleIcon size={17} /><span>{error}</span><button type="button" onClick={() => setError("")}>关闭</button></div> : null}
|
||||
{renderWorkspace()}
|
||||
</div>
|
||||
|
||||
<aside className="agent-panel">
|
||||
<div className="agent-heading"><span><SparkleIcon size={15} weight="fill" /></span><div><strong>设计 Agent</strong><small>{readiness?.ready ? "模型与存储已就绪" : "等待系统配置"}</small></div></div>
|
||||
<div className="agent-stream">
|
||||
{!project ? <div className="agent-message"><p>上传户型图后,我会在每个关键决策点给出判断,也会主动追问缺失信息。</p></div> : null}
|
||||
{project?.events.slice(-6).map((event) => <div key={event.id} className={`agent-message ${event.kind === "user" ? "user" : ""}`}><small>{event.kind === "user" ? "你" : event.kind === "assistant" ? "设计 Agent" : "工作记录"}</small><p>{event.message}</p></div>)}
|
||||
{chatReply && !project?.events.some((event) => event.message === chatReply) ? <div className="agent-message"><p>{chatReply}</p></div> : null}
|
||||
</div>
|
||||
<form className="agent-input" onSubmit={chat}><textarea value={chatDraft} onChange={(event) => setChatDraft(event.target.value)} placeholder={project ? "告诉我你的偏好,或问我下一步怎么选" : "先上传图纸后开始对话"} disabled={!project || Boolean(busy)} /><button type="submit" disabled={!project || !chatDraft.trim() || Boolean(busy)} aria-label="发送"><PaperPlaneTiltIcon size={16} weight="fill" /></button></form>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<SettingsCenter open={settingsOpen} onClose={() => setSettingsOpen(false)} onReadinessChange={setReadiness} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type {
|
||||
ChatMessage,
|
||||
PlanLayer,
|
||||
StyleDirection,
|
||||
WorkflowStage,
|
||||
} from "@/types/workflow";
|
||||
|
||||
@@ -24,30 +23,6 @@ export const initialLayers: PlanLayer[] = [
|
||||
{ id: "labels", label: "房间文字", visible: false },
|
||||
];
|
||||
|
||||
export const styleDirections: StyleDirection[] = [
|
||||
{
|
||||
id: "quiet-modern",
|
||||
name: "清透现代",
|
||||
summary: "轻盈体块、浅木与柔和自然光,保持客餐厅的开阔关系。",
|
||||
colors: ["#E7E9E5", "#B8A68A", "#56695F"],
|
||||
materials: ["浅橡木", "亚麻", "哑光涂料"],
|
||||
},
|
||||
{
|
||||
id: "soft-architectural",
|
||||
name: "柔和构成",
|
||||
summary: "用低矮家具和圆角收束空间,重点塑造入口到客厅的连续视线。",
|
||||
colors: ["#D9D7D0", "#8F8B82", "#3E5149"],
|
||||
materials: ["微水泥", "烟熏木", "织物"],
|
||||
},
|
||||
{
|
||||
id: "natural-contrast",
|
||||
name: "自然对比",
|
||||
summary: "增加深木与石材对比,但限制高光材质,避免空间变得沉重。",
|
||||
colors: ["#E5E1D7", "#8B735F", "#31443B"],
|
||||
materials: ["洞石", "深木", "羊毛"],
|
||||
},
|
||||
];
|
||||
|
||||
export const initialMessages: ChatMessage[] = [
|
||||
{
|
||||
id: "assistant-1",
|
||||
|
||||
@@ -62,6 +62,25 @@ export interface PlanState {
|
||||
issues: PlanIssue[];
|
||||
scale_mm_per_unit?: number | null;
|
||||
ceiling_height_mm: number;
|
||||
gross_area_sqm?: number | null;
|
||||
structure: {
|
||||
status: string;
|
||||
summary: string;
|
||||
rooms: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
area_sqm?: number | null;
|
||||
confidence: number;
|
||||
notes: string;
|
||||
}>;
|
||||
circulation: string;
|
||||
daylight: string;
|
||||
layout_opportunities: string[];
|
||||
risks: string[];
|
||||
model_name: string;
|
||||
degraded: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProjectSnapshot {
|
||||
@@ -86,17 +105,52 @@ export interface ProjectSnapshot {
|
||||
density: string;
|
||||
avoid: string[];
|
||||
locked_decisions: string[];
|
||||
selected_direction_id?: string | null;
|
||||
};
|
||||
brief: DesignBrief;
|
||||
directions: StyleDirection[];
|
||||
renders: RenderAsset[];
|
||||
events: Array<{ id: string; kind: string; message: string; created_at: string }>;
|
||||
available_commands: string[];
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DesignBrief {
|
||||
residents: string;
|
||||
lifestyle: string[];
|
||||
focus_rooms: string[];
|
||||
preferred_styles: string[];
|
||||
preferred_colors: string[];
|
||||
disliked_elements: string[];
|
||||
must_keep: string[];
|
||||
budget_level: string;
|
||||
additional_notes: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export interface RenderAsset {
|
||||
id: string;
|
||||
direction_id: string;
|
||||
room: string;
|
||||
view: string;
|
||||
object_key: string;
|
||||
prompt: string;
|
||||
model_name: string;
|
||||
parent_id?: string | null;
|
||||
edit_instruction: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface StyleDirection {
|
||||
id: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
colors: string[];
|
||||
thesis: string;
|
||||
keywords: string[];
|
||||
palette: Array<{ name: string; hex: string; role: string }>;
|
||||
materials: string[];
|
||||
lighting: string;
|
||||
prompt: string;
|
||||
model_name: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
|
||||
Reference in New Issue
Block a user