Files
zhuangxiu/apps/web/components/workflow-studio.tsx
T

512 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
ArrowRightIcon,
BuildingsIcon,
CheckIcon,
CircleNotchIcon,
CubeIcon,
DownloadSimpleIcon,
GearSixIcon,
ImageIcon,
MagicWandIcon,
PaperPlaneTiltIcon,
PencilSimpleIcon,
PlusIcon,
SparkleIcon,
UploadSimpleIcon,
WarningCircleIcon,
} from "@phosphor-icons/react";
import { ChangeEvent, FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { SettingsCenter } from "@/components/settings-center";
import type { DesignBrief, ProjectSnapshot, RenderAsset, WorkflowStageId } from "@/types/workflow";
import type { SettingsReadiness } from "@/types/settings";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
const EMPTY_BRIEF: DesignBrief = {
residents: "",
lifestyle: [],
focus_rooms: [],
preferred_styles: [],
preferred_colors: [],
disliked_elements: [],
must_keep: [],
budget_level: "适中",
additional_notes: "",
completed: false,
};
const STAGES: Array<{ ids: Array<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 [readiness, setReadiness] = useState<SettingsReadiness | null>(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 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);
setBrief(next.brief ?? EMPTY_BRIEF);
setGrossArea(next.plan.gross_area_sqm ? String(next.plan.gross_area_sqm) : "");
setCeilingHeight(String(next.plan.ceiling_height_mm || 2800));
setRoomNames(next.plan.structure?.rooms?.map((room) => room.name).join("、") ?? "");
if (next.plan.structure?.rooms?.[0]?.name) setRenderRoom(next.plan.structure.rooms[0].name);
if (next.renders.length) setActiveRenderId(next.renders[next.renders.length - 1].id);
}
useEffect(() => {
async function boot() {
try {
const [readinessResponse, projectsResponse] = await Promise.all([
fetch(`${API_BASE}/v1/readiness`, { cache: "no-store" }),
fetch(`${API_BASE}/v1/projects`, { cache: "no-store" }),
]);
if (readinessResponse.ok) {
const current = await readinessResponse.json() as SettingsReadiness;
setReadiness(current);
if (!current.ready) setSettingsOpen(true);
}
if (projectsResponse.ok) {
const projects = await projectsResponse.json() as ProjectSnapshot[];
const latest = projects.find((item) => item.project_id !== "demo-apartment");
if (latest) applyProject(latest);
}
} catch {
setError("无法连接本地服务,请检查 Docker 是否正在运行。");
} finally {
setLoading(false);
}
}
void boot();
}, []);
async function apiError(response: Response, fallback: string) {
try {
const body = await response.json() as { detail?: string | { message?: string } };
if (typeof body.detail === "string") return body.detail;
if (body.detail?.message) return body.detail.message;
} catch { /* use fallback */ }
return fallback;
}
async function command(base: ProjectSnapshot, name: string, payload: Record<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: name, expected_revision: base.revision, payload }),
});
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;
}
setBusy("正在读取 PDF 并寻找户型区域");
setError("");
const form = new FormData();
form.append("file", file);
form.append("project_name", `${file.name.replace(/\.pdf$/i, "")} 风格方案`);
try {
const response = await fetch(`${API_BASE}/v1/projects/ingest`, { method: "POST", body: form });
if (!response.ok) throw new Error(await apiError(response, "图纸上传失败。"));
applyProject(await response.json() as ProjectSnapshot);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "图纸上传失败。");
} finally {
setBusy("");
if (fileInput.current) fileInput.current.value = "";
}
}
function onFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (file) void upload(file);
}
async function chooseRegion(id: string) {
if (!project) return;
setBusy("正在建立目标户型");
setError("");
try { applyProject(await command(project, "select_region", { region_id: id })); }
catch (reason) { setError(reason instanceof Error ? reason.message : "选择失败。"); }
finally { setBusy(""); }
}
async function analyzePlan() {
if (!project) return;
setBusy("空间模型正在识别房间、动线与采光");
setError("");
try {
const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/spatial-analysis`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ gross_area_sqm: grossArea ? Number(grossArea) : null }),
});
if (!response.ok) throw new Error(await apiError(response, "空间分析失败。"));
applyProject(await response.json() as ProjectSnapshot);
} catch (reason) { setError(reason instanceof Error ? reason.message : "空间分析失败。"); }
finally { setBusy(""); }
}
async function confirmStructure() {
if (!project) return;
setBusy("正在建立空间骨架和推荐视角");
setError("");
try {
const confirmed = project.stage === "blockout"
? project
: await command(project, "confirm_plan", {
gross_area_sqm: grossArea ? Number(grossArea) : null,
ceiling_height_mm: Number(ceilingHeight) || 2800,
room_names: roomNames.split(/[、,\n]/).map((item) => item.trim()).filter(Boolean),
});
applyProject(await command(confirmed, "build_blockout"));
} catch (reason) { setError(reason instanceof Error ? reason.message : "结构确认失败。"); }
finally { setBusy(""); }
}
async function createDirections() {
if (!project) return;
setBusy("设计总监正在生成三套 Style DNA");
setError("");
try {
const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/style-directions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brief }),
});
if (!response.ok) throw new Error(await apiError(response, "风格方向生成失败。"));
applyProject(await response.json() as ProjectSnapshot);
} catch (reason) { setError(reason instanceof Error ? reason.message : "风格方向生成失败。"); }
finally { setBusy(""); }
}
async function selectDirection(id: string) {
if (!project) return;
setBusy("正在锁定设计方向");
setError("");
try { applyProject(await command(project, "select_direction", { direction_id: id })); }
catch (reason) { setError(reason instanceof Error ? reason.message : "方向选择失败。"); }
finally { setBusy(""); }
}
async function generateRender() {
if (!project?.style.selected_direction_id) return;
setBusy("真实生图模型正在生成效果图,通常需要 20 秒到 2 分钟");
setError("");
try {
const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/renders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ direction_id: project.style.selected_direction_id, room: renderRoom, view: renderView }),
});
if (!response.ok) throw new Error(await apiError(response, "效果图生成失败。"));
applyProject(await response.json() as ProjectSnapshot);
} catch (reason) { setError(reason instanceof Error ? reason.message : "效果图生成失败。"); }
finally { setBusy(""); }
}
async function editRender(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!project || !activeRender || !editInstruction.trim()) return;
setBusy("正在只修改指定内容,并保持其他区域不变");
setError("");
try {
const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/renders/${activeRender.id}/edit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ instruction: editInstruction.trim() }),
});
if (!response.ok) throw new Error(await apiError(response, "效果图修改失败。"));
applyProject(await response.json() as ProjectSnapshot);
setEditInstruction("");
} catch (reason) { setError(reason instanceof Error ? reason.message : "效果图修改失败。"); }
finally { setBusy(""); }
}
async function chat(event: FormEvent<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 (
<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>
<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>
);
}