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