feat: scaffold agentic interior design workflow
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.py]
|
||||||
|
indent_size = 4
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
# 作用:标记当前运行环境并控制日志、调试和安全默认值。必填。可选值为 development、test、production。
|
||||||
|
APP_ENV=development
|
||||||
|
|
||||||
|
# 作用:供日志和可观测性识别当前服务。必填。通常无需修改。
|
||||||
|
APP_NAME=zhuangxiu-api
|
||||||
|
|
||||||
|
# 作用:控制后端日志输出级别。必填。开发使用 INFO,排障可临时改为 DEBUG,生产不建议 DEBUG。
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# 作用:后端监听地址。必填。容器内使用 0.0.0.0,本机仅监听本地时可用 127.0.0.1。
|
||||||
|
API_HOST=0.0.0.0
|
||||||
|
|
||||||
|
# 作用:后端 HTTP 端口。必填。修改后需要同步更新 NEXT_PUBLIC_API_BASE_URL。
|
||||||
|
API_PORT=8000
|
||||||
|
|
||||||
|
# 作用:浏览器允许访问后端的前端来源,多个来源使用英文逗号分隔。必填。生产环境不要使用星号。
|
||||||
|
CORS_ORIGINS=http://localhost:3000
|
||||||
|
|
||||||
|
# 作用:前端访问后端 API 的公开地址。必填。该值会进入浏览器代码,不得包含任何密钥。
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# 作用:PostgreSQL 数据库名。必填。Docker Compose 会用它初始化数据库。
|
||||||
|
POSTGRES_DB=zhuangxiu
|
||||||
|
|
||||||
|
# 作用:PostgreSQL 应用账号。必填。生产环境不要使用 postgres 超级用户。
|
||||||
|
POSTGRES_USER=zhuangxiu
|
||||||
|
|
||||||
|
# 作用:PostgreSQL 应用密码。必填。使用 `openssl rand -hex 24` 单独生成,不要与其他密码复用。
|
||||||
|
POSTGRES_PASSWORD=replace_with_random_hex
|
||||||
|
|
||||||
|
# 作用:后端连接 PostgreSQL 的完整地址。必填。若密码包含特殊字符需要做 URL 编码。
|
||||||
|
DATABASE_URL=postgresql+psycopg://zhuangxiu:replace_with_random_hex@postgres:5432/zhuangxiu
|
||||||
|
|
||||||
|
# 作用:Redis 访问密码。必填。使用 `openssl rand -hex 24` 生成,生产环境不可留空。
|
||||||
|
REDIS_PASSWORD=replace_with_random_hex
|
||||||
|
|
||||||
|
# 作用:后端连接 Redis 的完整地址,用于队列、缓存和分布式锁。必填。
|
||||||
|
REDIS_URL=redis://:replace_with_random_hex@redis:6379/0
|
||||||
|
|
||||||
|
# 作用:MinIO 的内部 S3 API 地址。必填。应用与 NAS 同网时填写 NAS 内网地址和 API 端口,不是控制台端口。
|
||||||
|
S3_ENDPOINT=http://192.168.200.36:9000
|
||||||
|
|
||||||
|
# 作用:浏览器通过预签名 URL 访问 MinIO 的公开 HTTPS 地址。外部用户上传时必填,纯内网开发可与 S3_ENDPOINT 相同。
|
||||||
|
S3_PUBLIC_ENDPOINT=http://192.168.200.36:9000
|
||||||
|
|
||||||
|
# 作用:MinIO 专用服务账号的 Access Key。必填。不要填写 MINIO_ROOT_USER,也不要暴露给前端。
|
||||||
|
S3_ACCESS_KEY_ID=replace_with_minio_service_account
|
||||||
|
|
||||||
|
# 作用:MinIO 专用服务账号的 Secret Key。必填。由 MinIO 创建服务账号时生成,只保存在后端。
|
||||||
|
S3_SECRET_ACCESS_KEY=replace_with_minio_service_secret
|
||||||
|
|
||||||
|
# 作用:S3 签名使用的区域。MinIO 通常使用 us-east-1,除非服务端配置了其他区域。
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
|
||||||
|
# 作用:保存用户原始 PDF、DXF 和户型图片的私有 bucket。必填。
|
||||||
|
S3_BUCKET_INPUTS=renovation-inputs
|
||||||
|
|
||||||
|
# 作用:保存 SVG、蒙版、控制图和 3D 白模等中间结果的私有 bucket。必填。
|
||||||
|
S3_BUCKET_DERIVED=renovation-derived
|
||||||
|
|
||||||
|
# 作用:保存最终效果图、局部修改图和版本预览的私有 bucket。必填。
|
||||||
|
S3_BUCKET_RENDERS=renovation-renders
|
||||||
|
|
||||||
|
# 作用:强制使用路径式 S3 地址。MinIO 通常需要 true,AWS S3 通常可以为 false。
|
||||||
|
S3_FORCE_PATH_STYLE=true
|
||||||
|
|
||||||
|
# 作用:预签名上传和下载 URL 的有效秒数。必填。建议保持较短,默认 15 分钟。
|
||||||
|
S3_PRESIGNED_URL_TTL_SECONDS=900
|
||||||
|
|
||||||
|
# 作用:启用百度 OCR。矢量 PDF 主流程不依赖 OCR,扫描图或文字层损坏时设为 true。
|
||||||
|
BAIDU_OCR_ENABLED=false
|
||||||
|
|
||||||
|
# 作用:百度智能云 OCR 应用的 API Key。启用百度 OCR 时必填,只能放在后端。
|
||||||
|
BAIDU_OCR_API_KEY=
|
||||||
|
|
||||||
|
# 作用:百度智能云 OCR 应用的 Secret Key。启用百度 OCR 时必填,不得写入日志或返回前端。
|
||||||
|
BAIDU_OCR_SECRET_KEY=
|
||||||
|
|
||||||
|
# 作用:百度 OCR OAuth Token 接口地址。通常无需修改,保留配置便于企业代理或兼容服务。
|
||||||
|
BAIDU_OCR_TOKEN_URL=https://aip.baidubce.com/oauth/2.0/token
|
||||||
|
|
||||||
|
# 作用:默认大语言模型供应商名称。必填。当前适配层预留 openai、gemini 和 compatible。
|
||||||
|
LLM_DEFAULT_PROVIDER=openai
|
||||||
|
|
||||||
|
# 作用:OpenAI 或 OpenAI 兼容服务的 API Key。使用该供应商时必填,只在服务端读取。
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
|
||||||
|
# 作用:OpenAI 兼容 API 的基础地址。官方服务保留默认值,使用代理或兼容服务时修改。
|
||||||
|
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
|
||||||
|
# 作用:空间理解与总调度默认模型名称。必填。实际模型需与所选供应商账号权限一致。
|
||||||
|
OPENAI_TEXT_MODEL=gpt-5
|
||||||
|
|
||||||
|
# 作用:OpenAI 图像生成或编辑默认模型。启用 OpenAI 图像适配器时必填。
|
||||||
|
OPENAI_IMAGE_MODEL=gpt-image-2
|
||||||
|
|
||||||
|
# 作用:Google Gemini API Key。启用 Gemini 或 Nano Banana 图像适配器时必填。
|
||||||
|
GEMINI_API_KEY=
|
||||||
|
|
||||||
|
# 作用:Gemini 多模态推理默认模型名称。启用 Gemini 时必填。
|
||||||
|
GEMINI_MODEL=gemini-3-pro
|
||||||
|
|
||||||
|
# 作用:火山方舟模型调用 API Key。启用 Seedream 时必填,建议使用方舟专用 Key 而非云账号长期 AK/SK。
|
||||||
|
ARK_API_KEY=
|
||||||
|
|
||||||
|
# 作用:Seedream 图像模型的 Endpoint ID 或模型标识。启用 Seedream 时必填,以火山方舟控制台显示值为准。
|
||||||
|
SEEDREAM_MODEL_ENDPOINT=
|
||||||
|
|
||||||
|
# 作用:优先使用的生图供应商顺序,多个值使用英文逗号分隔。必填。路由器会按任务能力和可用性选择。
|
||||||
|
IMAGE_PROVIDER_PRIORITY=seedream,openai,gemini
|
||||||
|
|
||||||
|
# 作用:本地 GPU 推理服务地址。启用本地户型分割、深度估计或审美评分时必填,仅使用内网地址。
|
||||||
|
GPU_SERVICE_URL=http://gpu-worker:8100
|
||||||
|
|
||||||
|
# 作用:业务后端调用 GPU 服务时使用的内部 Bearer Token。启用 GPU 服务时必填,使用 `openssl rand -hex 32` 生成。
|
||||||
|
GPU_SERVICE_TOKEN=replace_with_random_hex
|
||||||
|
|
||||||
|
# 作用:用户登录 JWT 的签名密钥。启用用户系统时必填,使用 `openssl rand -hex 32` 独立生成。
|
||||||
|
JWT_SECRET=replace_with_random_hex
|
||||||
|
|
||||||
|
# 作用:签名或加密会话 Cookie 和 CSRF 状态。启用用户系统时必填,使用 `openssl rand -hex 32` 生成,不得与 JWT_SECRET 共用。
|
||||||
|
SESSION_SECRET=replace_with_random_hex
|
||||||
|
|
||||||
|
# 作用:加密数据库中的用户模型 Key 和第三方令牌。必填。使用 `openssl rand -base64 32` 生成,丢失后旧密文无法恢复。
|
||||||
|
APP_ENCRYPTION_KEY=replace_with_32_byte_base64_key
|
||||||
|
|
||||||
|
# 作用:验证内部异步回调的 HMAC 签名。启用 Worker 回调时必填,使用 `openssl rand -hex 32` 独立生成。
|
||||||
|
WEBHOOK_SIGNING_SECRET=replace_with_random_hex
|
||||||
|
|
||||||
|
# 作用:启用 Langfuse 调用链追踪。开发初期可以关闭,准备评测不同模型后建议开启。
|
||||||
|
LANGFUSE_ENABLED=false
|
||||||
|
|
||||||
|
# 作用:Langfuse 服务地址。自部署时填写内网或 HTTPS 地址,关闭追踪时可保留默认值。
|
||||||
|
LANGFUSE_HOST=http://langfuse-web:3000
|
||||||
|
|
||||||
|
# 作用:Langfuse 项目的 Public Key。启用追踪时必填,该值用于标识项目但仍不应放入公开仓库。
|
||||||
|
LANGFUSE_PUBLIC_KEY=
|
||||||
|
|
||||||
|
# 作用:Langfuse 项目的 Secret Key。启用追踪时必填,只能由后端和 Worker 使用。
|
||||||
|
LANGFUSE_SECRET_KEY=
|
||||||
|
|
||||||
|
# 作用:Sentry DSN,用于收集后端异常和性能事件。未部署或暂不使用 Sentry 时留空。
|
||||||
|
SENTRY_DSN=
|
||||||
|
|
||||||
|
# 作用:标记 Sentry 事件所属环境。填写 development、staging 或 production,便于区分告警。
|
||||||
|
SENTRY_ENVIRONMENT=development
|
||||||
|
|
||||||
|
# 作用:Sentry 性能追踪采样率,范围 0 到 1。生产初期建议 0.05 到 0.2,开发可设为 0。
|
||||||
|
SENTRY_TRACES_SAMPLE_RATE=0
|
||||||
+3
-1
@@ -12,6 +12,7 @@ temp/
|
|||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
@@ -20,6 +21,8 @@ venv/
|
|||||||
|
|
||||||
# Node.js
|
# Node.js
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
*.tsbuildinfo
|
||||||
.next/
|
.next/
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
@@ -30,4 +33,3 @@ coverage/
|
|||||||
.vscode/
|
.vscode/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# AI 空间风格工作台
|
||||||
|
|
||||||
|
这是一个以户型为约束、以布局与风格决策为核心的 Agent 型室内概念设计工作流。
|
||||||
|
|
||||||
|
第一阶段不追求施工图、真实商品匹配或精确报价,而是建立三个可持续修改的核心状态:
|
||||||
|
|
||||||
|
- `Plan State`:墙、门窗、房间、比例、图层和置信度。
|
||||||
|
- `Scene State`:家具布局、相机、锁定对象和 3D 白模。
|
||||||
|
- `Style State`:色板、材质、灯光、造型语言、禁用项和已锁定决策。
|
||||||
|
|
||||||
|
## 仓库结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/web Next.js 风格工作台
|
||||||
|
services/api FastAPI 工作流与集成接口
|
||||||
|
contracts 前后端共享的数据协议说明
|
||||||
|
docs 架构与工作流设计
|
||||||
|
11-2-104-模型.pdf 真实测试图纸
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地启动
|
||||||
|
|
||||||
|
1. 将 `.env.example` 复制为 `.env`,按注释填写配置。
|
||||||
|
2. 安装前端依赖:`pnpm install`。
|
||||||
|
3. 启动前端:`pnpm dev`。
|
||||||
|
4. 创建 Python 虚拟环境并安装 API:`pip install -e "services/api[dev]"`。
|
||||||
|
5. 启动 API:`uvicorn app.main:app --app-dir services/api --reload --port 8000`。
|
||||||
|
|
||||||
|
前端默认访问 `http://localhost:3000`,API 文档默认位于 `http://localhost:8000/docs`。
|
||||||
|
|
||||||
|
## 当前垂直切片
|
||||||
|
|
||||||
|
- 显示样例户型与图层开关。
|
||||||
|
- 展示从导入、清洗、白模到风格方向和多轮修改的阶段状态。
|
||||||
|
- 提供结构化 Style DNA 面板。
|
||||||
|
- 提供可验证的工作流状态机和命令接口。
|
||||||
|
- 预留 MinIO、百度 OCR、GPU Worker 和多模型路由适配器。
|
||||||
|
|
||||||
|
详见 [架构设计](docs/architecture.md) 与 [工作流定义](docs/workflow.md)。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
FROM node:22-alpine AS dependencies
|
||||||
|
WORKDIR /app
|
||||||
|
RUN corepack enable
|
||||||
|
COPY package.json ./
|
||||||
|
RUN pnpm install --frozen-lockfile=false
|
||||||
|
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
RUN corepack enable
|
||||||
|
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||||
|
ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}
|
||||||
|
ENV NEXT_OUTPUT_MODE=standalone
|
||||||
|
COPY --from=dependencies /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,945 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--bg: #e8ebe8;
|
||||||
|
--surface: #f5f6f3;
|
||||||
|
--surface-raised: #fbfcf9;
|
||||||
|
--surface-muted: #e1e5e1;
|
||||||
|
--canvas: #d7dbd6;
|
||||||
|
--text: #1e2521;
|
||||||
|
--text-soft: #66706a;
|
||||||
|
--text-faint: #8b948e;
|
||||||
|
--line: #cdd3ce;
|
||||||
|
--line-strong: #b9c1bb;
|
||||||
|
--accent: #3f6757;
|
||||||
|
--accent-strong: #2f5244;
|
||||||
|
--accent-soft: #d9e6df;
|
||||||
|
--warning: #9a682d;
|
||||||
|
--warning-soft: #f0e5d2;
|
||||||
|
--shadow: 0 18px 55px rgb(49 67 58 / 0.1);
|
||||||
|
--radius-surface: 14px;
|
||||||
|
--radius-control: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #1b211e;
|
||||||
|
--surface: #222a26;
|
||||||
|
--surface-raised: #29312d;
|
||||||
|
--surface-muted: #303934;
|
||||||
|
--canvas: #171c19;
|
||||||
|
--text: #edf0ec;
|
||||||
|
--text-soft: #b1bab4;
|
||||||
|
--text-faint: #87918b;
|
||||||
|
--line: #3a443e;
|
||||||
|
--line-strong: #4b5750;
|
||||||
|
--accent: #82ac99;
|
||||||
|
--accent-strong: #9abbaa;
|
||||||
|
--accent-soft: #31483d;
|
||||||
|
--warning: #d5a05d;
|
||||||
|
--warning-soft: #443726;
|
||||||
|
--shadow: 0 20px 60px rgb(7 11 8 / 0.28);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
body,
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
font-family: "Segoe UI Variable", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
textarea:focus-visible,
|
||||||
|
[role="tab"]:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-shell {
|
||||||
|
min-height: 100dvh;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 14% -10%, rgb(109 142 124 / 0.12), transparent 32%),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
height: 70px;
|
||||||
|
padding: 0 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: color-mix(in srgb, var(--surface) 88%, transparent);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-block,
|
||||||
|
.topbar-actions,
|
||||||
|
.canvas-actions,
|
||||||
|
.save-state,
|
||||||
|
.button,
|
||||||
|
.tab-trigger,
|
||||||
|
.canvas-status,
|
||||||
|
.lock-label,
|
||||||
|
.inspector-footer > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-block {
|
||||||
|
gap: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
color: #eef3ef;
|
||||||
|
background: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name,
|
||||||
|
.brand-subtitle,
|
||||||
|
.rail-heading p,
|
||||||
|
.panel-title,
|
||||||
|
.panel-caption,
|
||||||
|
.helper-text,
|
||||||
|
.style-label,
|
||||||
|
.constraint-section p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 680;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-subtitle {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-state {
|
||||||
|
gap: 5px;
|
||||||
|
margin-right: 6px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button,
|
||||||
|
.icon-button,
|
||||||
|
.send-button {
|
||||||
|
min-height: 36px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 620;
|
||||||
|
transition: background-color 160ms ease, border-color 160ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 0 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:active,
|
||||||
|
.icon-button:active,
|
||||||
|
.send-button:active,
|
||||||
|
.stage-item:active,
|
||||||
|
.layer-row:active,
|
||||||
|
.direction-option:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary {
|
||||||
|
color: #f1f5f2;
|
||||||
|
background: var(--accent-strong);
|
||||||
|
border-color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary:hover,
|
||||||
|
.icon-button:hover {
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.compact {
|
||||||
|
min-height: 34px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-grid {
|
||||||
|
min-height: calc(100dvh - 70px);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 210px minmax(560px, 1fr) 330px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-rail,
|
||||||
|
.style-inspector {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-rail {
|
||||||
|
padding: 22px 14px;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-heading {
|
||||||
|
padding: 0 8px 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-heading p {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-item {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 24px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
color: var(--text-soft);
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-item:hover {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-active {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 35%, var(--line));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-index {
|
||||||
|
width: 21px;
|
||||||
|
height: 21px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-complete .stage-index {
|
||||||
|
color: #edf4ef;
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-active .stage-index {
|
||||||
|
border: 6px solid var(--accent);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-copy strong {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 630;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-copy small {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-note {
|
||||||
|
margin: 24px 4px 0;
|
||||||
|
padding: 12px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--warning);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--warning-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-note p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: minmax(540px, 1fr) auto;
|
||||||
|
background: var(--canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-tabs,
|
||||||
|
.tab-content {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-toolbar {
|
||||||
|
height: 54px;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-trigger {
|
||||||
|
min-height: 34px;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 11px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-trigger[data-state="active"] {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-actions {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button,
|
||||||
|
.send-button {
|
||||||
|
width: 36px;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--text-soft);
|
||||||
|
border-color: var(--line);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button.small {
|
||||||
|
width: 30px;
|
||||||
|
min-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
z-index: 20;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--surface-raised);
|
||||||
|
background: var(--text);
|
||||||
|
font-size: 11px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-workspace {
|
||||||
|
height: calc(100% - 54px);
|
||||||
|
min-height: 486px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 210px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-canvas {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 18px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-labels,
|
||||||
|
.canvas-status {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-labels span:first-child {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-image-frame {
|
||||||
|
position: relative;
|
||||||
|
min-height: 405px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-surface);
|
||||||
|
background: #f1f2ee;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-image {
|
||||||
|
object-fit: contain;
|
||||||
|
padding: 12px;
|
||||||
|
filter: contrast(1.02) saturate(0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-outline {
|
||||||
|
position: absolute;
|
||||||
|
inset: 8% 10% 9% 9%;
|
||||||
|
border: 2px solid color-mix(in srgb, var(--accent) 78%, transparent);
|
||||||
|
border-radius: 9px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-marker {
|
||||||
|
position: absolute;
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #fff9ef;
|
||||||
|
border: 2px solid #f7ead5;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--warning);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 5px 18px rgb(104 71 32 / 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-one {
|
||||||
|
left: 31%;
|
||||||
|
top: 24%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-two {
|
||||||
|
right: 26%;
|
||||||
|
bottom: 23%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-status {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-status span:first-child {
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-status span:last-child {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-panel {
|
||||||
|
padding: 18px 14px;
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title-row,
|
||||||
|
.inspector-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 680;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-caption {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-list {
|
||||||
|
margin-top: 15px;
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row {
|
||||||
|
min-height: 37px;
|
||||||
|
padding: 0 9px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20px 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row:hover {
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row.is-visible {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row small {
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-divider {
|
||||||
|
height: 1px;
|
||||||
|
margin: 18px 0;
|
||||||
|
background: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 3px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
background: transparent;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control button.active {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: 0 2px 7px rgb(49 67 58 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.helper-text {
|
||||||
|
margin-top: 10px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-workspace {
|
||||||
|
min-height: 486px;
|
||||||
|
padding: 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-workspace h2 {
|
||||||
|
margin: 14px 0 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-workspace p {
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation {
|
||||||
|
min-height: 210px;
|
||||||
|
padding: 14px 18px 16px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-stream {
|
||||||
|
max-height: 132px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
max-width: 84%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-user {
|
||||||
|
justify-self: end;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding: 9px 11px;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-icon {
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message p {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message small {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 9px 10px 8px 12px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-surface);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer label {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer input {
|
||||||
|
min-width: 0;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
color: var(--text);
|
||||||
|
background: transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer input::placeholder {
|
||||||
|
color: var(--text-faint);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer input:focus-visible {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button {
|
||||||
|
width: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
color: #f2f6f3;
|
||||||
|
border: 0;
|
||||||
|
background: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-inspector {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 20px 18px;
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-label {
|
||||||
|
gap: 4px;
|
||||||
|
padding: 5px 7px;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-section {
|
||||||
|
margin-top: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-section label,
|
||||||
|
.style-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-section textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 75px;
|
||||||
|
padding: 10px;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-option {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 75px;
|
||||||
|
padding: 9px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 38px 1fr 16px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-option:hover {
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-option.selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-colors {
|
||||||
|
height: 49px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: repeat(3, 1fr);
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid rgb(71 84 76 / 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-option > span:nth-child(2) {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-option strong {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-option small {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-list span,
|
||||||
|
.tag-list button {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 8px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.constraint-section {
|
||||||
|
padding: 11px;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.constraint-section p:last-child {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspector-footer {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspector-footer > div {
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.studio-grid {
|
||||||
|
grid-template-columns: 190px minmax(520px, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-inspector {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.topbar {
|
||||||
|
height: auto;
|
||||||
|
min-height: 66px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-state,
|
||||||
|
.topbar-actions .button-secondary,
|
||||||
|
.brand-subtitle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-grid {
|
||||||
|
min-height: calc(100dvh - 66px);
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-rail {
|
||||||
|
padding: 9px 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-heading,
|
||||||
|
.rail-note,
|
||||||
|
.stage-copy small {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-item {
|
||||||
|
min-width: 104px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-workspace {
|
||||||
|
height: auto;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-canvas {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-image-frame {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-panel {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-list {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation {
|
||||||
|
min-height: 230px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "空间风格工作台",
|
||||||
|
description: "从户型清洗到 Style DNA 和多轮效果图编辑的空间概念设计工作台。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { WorkflowStudio } from "@/components/workflow-studio";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return <WorkflowStudio />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
"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,
|
||||||
|
ImageIcon,
|
||||||
|
LockSimpleIcon,
|
||||||
|
PaperPlaneTiltIcon,
|
||||||
|
PlusIcon,
|
||||||
|
SlidersHorizontalIcon,
|
||||||
|
SparkleIcon,
|
||||||
|
UploadSimpleIcon,
|
||||||
|
WarningCircleIcon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { FormEvent, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
initialLayers,
|
||||||
|
initialMessages,
|
||||||
|
styleDirections,
|
||||||
|
workflowStages,
|
||||||
|
} from "@/lib/demo";
|
||||||
|
import type { ChatMessage } from "@/types/workflow";
|
||||||
|
|
||||||
|
const iconWeight = "regular" as const;
|
||||||
|
|
||||||
|
export function WorkflowStudio() {
|
||||||
|
const [layers, setLayers] = useState(initialLayers);
|
||||||
|
const [selectedDirection, setSelectedDirection] = useState("quiet-modern");
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [furnitureMode, setFurnitureMode] = useState<"reference" | "remove">("reference");
|
||||||
|
|
||||||
|
const currentDirection = useMemo(
|
||||||
|
() => styleDirections.find((item) => item.id === selectedDirection) ?? styleDirections[0],
|
||||||
|
[selectedDirection],
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleLayer(id: string) {
|
||||||
|
setLayers((items) =>
|
||||||
|
items.map((item) => (item.id === id ? { ...item, visible: !item.visible } : item)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMessage(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("");
|
||||||
|
}
|
||||||
|
|
||||||
|
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">11-2-104 住宅概念方案</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="topbar-actions">
|
||||||
|
<span className="save-state"><CheckIcon size={14} weight="bold" /> 已保存</span>
|
||||||
|
<button className="button button-secondary" type="button">版本记录</button>
|
||||||
|
<button className="button button-primary" type="button">
|
||||||
|
确认结构 <ArrowRightIcon size={16} weight="bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="studio-grid">
|
||||||
|
<aside className="workflow-rail" aria-label="设计流程">
|
||||||
|
<div className="rail-heading">
|
||||||
|
<p>设计流程</p>
|
||||||
|
<span>3 / 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>有 2 项需要确认,确认后才能生成空间白模。</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">
|
||||||
|
<UploadSimpleIcon size={16} /> 更换图纸
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs.Content value="plan" className="tab-content">
|
||||||
|
<div className="plan-workspace">
|
||||||
|
<div className="plan-canvas">
|
||||||
|
<div className="canvas-labels">
|
||||||
|
<span>上方住宅户型</span>
|
||||||
|
<span>矢量 PDF · 43 个 CAD 图层</span>
|
||||||
|
</div>
|
||||||
|
<div className="plan-image-frame">
|
||||||
|
<Image
|
||||||
|
src="/sample-plan.png"
|
||||||
|
alt="从真实 PDF 中分离出的住宅建筑结构平面图"
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
sizes="(max-width: 900px) 100vw, 60vw"
|
||||||
|
className="plan-image"
|
||||||
|
/>
|
||||||
|
<div className="region-outline" aria-hidden="true" />
|
||||||
|
<button className="issue-marker issue-one" type="button" aria-label="比例待确认">1</button>
|
||||||
|
<button className="issue-marker issue-two" type="button" aria-label="家具意图待确认">2</button>
|
||||||
|
</div>
|
||||||
|
<div className="canvas-status">
|
||||||
|
<span><CheckIcon size={14} weight="bold" /> 已分离建筑结构</span>
|
||||||
|
<span>比例待交叉校准</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="layer-panel">
|
||||||
|
<div className="panel-title-row">
|
||||||
|
<div>
|
||||||
|
<p className="panel-title">图层</p>
|
||||||
|
<p className="panel-caption">保留矢量信息</p>
|
||||||
|
</div>
|
||||||
|
<button className="icon-button small" type="button" aria-label="添加图层">
|
||||||
|
<PlusIcon size={16} />
|
||||||
|
</button>
|
||||||
|
</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}</small> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</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">
|
||||||
|
{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="例如:保留床和窗的位置,客厅更松弛,不要冷灰"
|
||||||
|
/>
|
||||||
|
<button className="send-button" type="submit" aria-label="发送设计指令">
|
||||||
|
<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 id="style-concept" defaultValue="温暖、克制、带自然材质感的现代住宅" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="style-section">
|
||||||
|
<p className="style-label">方向草案</p>
|
||||||
|
<div className="direction-list">
|
||||||
|
{styleDirections.map((direction) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={direction.id}
|
||||||
|
className={`direction-option ${selectedDirection === direction.id ? "selected" : ""}`}
|
||||||
|
onClick={() => setSelectedDirection(direction.id)}
|
||||||
|
>
|
||||||
|
<span className="direction-colors" aria-hidden="true">
|
||||||
|
{direction.colors.map((color) => (
|
||||||
|
<span key={color} style={{ backgroundColor: color }} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>{direction.name}</strong>
|
||||||
|
<small>{direction.summary}</small>
|
||||||
|
</span>
|
||||||
|
{selectedDirection === direction.id ? <CheckIcon size={15} weight="bold" /> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="style-section material-section">
|
||||||
|
<p className="style-label">材料语言</p>
|
||||||
|
<div className="tag-list">
|
||||||
|
{currentDirection.materials.map((material) => <span key={material}>{material}</span>)}
|
||||||
|
<button type="button"><PlusIcon size={13} /> 添加</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="style-section constraint-section">
|
||||||
|
<p className="style-label">明确避免</p>
|
||||||
|
<p>大面积冷灰、高亮岩板、过量灯带</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inspector-footer">
|
||||||
|
<div>
|
||||||
|
<CircleNotchIcon size={17} />
|
||||||
|
<span>等待结构确认</span>
|
||||||
|
</div>
|
||||||
|
<button className="button button-primary full" type="button">保存风格草案</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTypescript from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTypescript,
|
||||||
|
globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
|
||||||
|
]);
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type {
|
||||||
|
ChatMessage,
|
||||||
|
PlanLayer,
|
||||||
|
StyleDirection,
|
||||||
|
WorkflowStage,
|
||||||
|
} from "@/types/workflow";
|
||||||
|
|
||||||
|
export const workflowStages: WorkflowStage[] = [
|
||||||
|
{ id: "uploaded", label: "导入图纸", detail: "矢量 PDF", status: "complete" },
|
||||||
|
{ id: "region_selection", label: "选择户型", detail: "住宅区域", status: "complete" },
|
||||||
|
{ id: "plan_review", label: "确认结构", detail: "当前步骤", status: "active" },
|
||||||
|
{ id: "blockout", label: "生成白模", detail: "空间骨架", status: "pending" },
|
||||||
|
{ id: "style_brief", label: "明确风格", detail: "Style DNA", status: "pending" },
|
||||||
|
{ id: "direction_selection", label: "选择方向", detail: "三个方案", status: "pending" },
|
||||||
|
{ id: "render_review", label: "审阅效果", detail: "多视角", status: "pending" },
|
||||||
|
{ id: "editing", label: "多轮修改", detail: "局部编辑", status: "pending" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const initialLayers: PlanLayer[] = [
|
||||||
|
{ id: "structure", label: "建筑结构", visible: true, count: 18 },
|
||||||
|
{ id: "openings", label: "门窗", visible: true, count: 14 },
|
||||||
|
{ id: "furniture", label: "原家具", visible: false, count: 32 },
|
||||||
|
{ id: "dimensions", label: "尺寸标注", visible: false },
|
||||||
|
{ id: "labels", label: "房间文字", visible: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const styleDirections: StyleDirection[] = [
|
||||||
|
{
|
||||||
|
id: "quiet-modern",
|
||||||
|
name: "清透现代",
|
||||||
|
summary: "轻盈体块、浅木与柔和自然光,保持客餐厅的开阔关系。",
|
||||||
|
colors: ["#E7E9E5", "#B8A68A", "#56695F"],
|
||||||
|
materials: ["浅橡木", "亚麻", "哑光涂料"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "soft-architectural",
|
||||||
|
name: "柔和构成",
|
||||||
|
summary: "用低矮家具和圆角收束空间,重点塑造入口到客厅的连续视线。",
|
||||||
|
colors: ["#D9D7D0", "#8F8B82", "#3E5149"],
|
||||||
|
materials: ["微水泥", "烟熏木", "织物"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "natural-contrast",
|
||||||
|
name: "自然对比",
|
||||||
|
summary: "增加深木与石材对比,但限制高光材质,避免空间变得沉重。",
|
||||||
|
colors: ["#E5E1D7", "#8B735F", "#31443B"],
|
||||||
|
materials: ["洞石", "深木", "羊毛"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const initialMessages: ChatMessage[] = [
|
||||||
|
{
|
||||||
|
id: "assistant-1",
|
||||||
|
role: "assistant",
|
||||||
|
body: "我识别到两个平面区域,已选择上方住宅户型。结构层和门窗层可信度较高。",
|
||||||
|
meta: "图纸解析",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "assistant-2",
|
||||||
|
role: "assistant",
|
||||||
|
body: "请确认原家具的处理方式。当前建议保留家具位置作为参考,但不锁定具体款式。",
|
||||||
|
meta: "等待确认",
|
||||||
|
},
|
||||||
|
];
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
poweredByHeader: false,
|
||||||
|
...(process.env.NEXT_OUTPUT_MODE === "standalone" ? { output: "standalone" as const } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@zhuangxiu/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"next": "^16.0.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/react": "^19.2.0",
|
||||||
|
"@types/react-dom": "^19.2.0",
|
||||||
|
"eslint": "^9.0.0",
|
||||||
|
"eslint-config-next": "^16.0.0",
|
||||||
|
"typescript": "^5.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export type WorkflowStageId =
|
||||||
|
| "uploaded"
|
||||||
|
| "region_selection"
|
||||||
|
| "plan_review"
|
||||||
|
| "blockout"
|
||||||
|
| "style_brief"
|
||||||
|
| "direction_selection"
|
||||||
|
| "render_review"
|
||||||
|
| "editing"
|
||||||
|
| "completed";
|
||||||
|
|
||||||
|
export interface WorkflowStage {
|
||||||
|
id: WorkflowStageId;
|
||||||
|
label: string;
|
||||||
|
detail: string;
|
||||||
|
status: "complete" | "active" | "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanLayer {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
visible: boolean;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StyleDirection {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
summary: string;
|
||||||
|
colors: string[];
|
||||||
|
materials: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
role: "assistant" | "user";
|
||||||
|
body: string;
|
||||||
|
meta?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Workflow API Contract
|
||||||
|
|
||||||
|
后端的 Pydantic 模型是运行时事实来源,前端 TypeScript 类型保持同名字段。
|
||||||
|
|
||||||
|
## ProjectSnapshot
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"project_id": "demo-apartment",
|
||||||
|
"name": "11-2-104 住宅概念方案",
|
||||||
|
"stage": "plan_review",
|
||||||
|
"revision": 3,
|
||||||
|
"plan": {},
|
||||||
|
"scene": {},
|
||||||
|
"style": {},
|
||||||
|
"available_commands": ["confirm_plan"],
|
||||||
|
"updated_at": "2026-08-01T12:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CommandRequest
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "confirm_plan",
|
||||||
|
"expected_revision": 3,
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`expected_revision` 用于乐观锁。若客户端基于旧版本提交,服务端返回 `409 Conflict`,避免覆盖其他修改。
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
name: zhuangxiu
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7.4-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"]
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./services/api
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "${API_PORT:-8000}:8000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: ./apps/web
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
redis-data:
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# 系统架构
|
||||||
|
|
||||||
|
## 设计原则
|
||||||
|
|
||||||
|
1. 几何事实与生成图像分离。模型不能用像素结果覆盖墙、门窗和锁定布局。
|
||||||
|
2. 所有修改先转成结构化命令,再调用几何、风格或生图执行器。
|
||||||
|
3. 生图供应商通过统一适配器接入,业务流程不感知具体模型。
|
||||||
|
4. 每个派生文件都记录输入、配置、模型、版本和父版本,支持回退与分支。
|
||||||
|
5. 低置信度识别必须进入人工确认,不能静默猜测。
|
||||||
|
|
||||||
|
## 逻辑结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
Web Studio
|
||||||
|
|-- 图纸清洗
|
||||||
|
|-- 3D 白模与相机
|
||||||
|
|-- Style DNA
|
||||||
|
|-- 对话与版本分支
|
||||||
|
|
|
||||||
|
FastAPI Orchestrator
|
||||||
|
|-- Workflow State Machine
|
||||||
|
|-- Project Command Handler
|
||||||
|
|-- Model Router
|
||||||
|
|-- Signed Upload Service
|
||||||
|
|
|
||||||
|
Workers
|
||||||
|
|-- Vector PDF Parser
|
||||||
|
|-- OCR Adapter
|
||||||
|
|-- Geometry Builder
|
||||||
|
|-- GPU Vision Service
|
||||||
|
|-- Image Generation Adapter
|
||||||
|
|
|
||||||
|
Storage
|
||||||
|
|-- PostgreSQL: 状态与版本
|
||||||
|
|-- Redis: 队列、缓存和锁
|
||||||
|
|-- MinIO: PDF、SVG、GLB、控制图和效果图
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心状态
|
||||||
|
|
||||||
|
### Plan State
|
||||||
|
|
||||||
|
保存可验证的空间事实:页面区域、比例、房间多边形、墙线、门窗、柱、楼梯、原始家具层和识别置信度。
|
||||||
|
|
||||||
|
### Scene State
|
||||||
|
|
||||||
|
保存 3D 白模、家具类别级占位、相机、可见性以及用户锁定对象。局部修改必须尊重锁定项。
|
||||||
|
|
||||||
|
### Style State
|
||||||
|
|
||||||
|
保存概念描述、色板、材质、灯光、形态、空间密度、禁用项和用户确认的设计决定。它是跨视角和跨轮次的一致性来源。
|
||||||
|
|
||||||
|
## 部署建议
|
||||||
|
|
||||||
|
- NAS MinIO:保存项目大文件,按应用和 Langfuse 分离 bucket 与账号。
|
||||||
|
- Linux Docker 服务器:运行 API、PostgreSQL、Redis、Langfuse 和普通 Worker。
|
||||||
|
- Linux GPU 服务器:运行户型分割、视觉评审、深度估计和可选本地模型。
|
||||||
|
- 百度 OCR:仅在 PDF 文字层失效或输入为扫描图时使用。
|
||||||
|
|
||||||
|
## 安全边界
|
||||||
|
|
||||||
|
- 浏览器不接触 MinIO、OCR 或模型服务的永久密钥。
|
||||||
|
- 上传和下载通过短期预签名 URL。
|
||||||
|
- 用户提供的模型 Key 使用 `APP_ENCRYPTION_KEY` 加密后存储。
|
||||||
|
- GPU 和内部 Worker 只接受内网请求,并校验内部服务令牌。
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# 工作流定义
|
||||||
|
|
||||||
|
## 阶段
|
||||||
|
|
||||||
|
```text
|
||||||
|
uploaded
|
||||||
|
-> region_selection
|
||||||
|
-> plan_review
|
||||||
|
-> blockout
|
||||||
|
-> style_brief
|
||||||
|
-> direction_selection
|
||||||
|
-> render_review
|
||||||
|
-> editing
|
||||||
|
-> completed
|
||||||
|
```
|
||||||
|
|
||||||
|
任意异步阶段都可以进入 `failed`,修正问题后通过 `retry` 回到最近一个可恢复阶段。
|
||||||
|
|
||||||
|
## 关键命令
|
||||||
|
|
||||||
|
| 命令 | 作用 | 关键约束 |
|
||||||
|
|---|---|---|
|
||||||
|
| `start_ingestion` | 分析上传文件 | 先判断矢量 PDF、DXF 或扫描图 |
|
||||||
|
| `select_region` | 选择目标户型区域 | 一张图纸可能包含多个平面区域 |
|
||||||
|
| `confirm_plan` | 确认清洗后的结构 | 必须处理低置信度墙、门窗和比例 |
|
||||||
|
| `build_blockout` | 生成 3D 白模与相机 | 不追求施工级构造 |
|
||||||
|
| `submit_style_brief` | 建立 Style DNA | 用户意图写入结构化字段 |
|
||||||
|
| `select_direction` | 锁定设计方向 | 保留方向分支和父版本 |
|
||||||
|
| `request_render` | 生成一个或多个视角 | 携带空间控制图与锁定状态 |
|
||||||
|
| `apply_edit` | 执行局部或全局修改 | 只变更受影响的状态与视角 |
|
||||||
|
| `complete_project` | 完成当前概念方案 | 输出仍标注为概念效果图 |
|
||||||
|
|
||||||
|
## Agent 分工
|
||||||
|
|
||||||
|
- Orchestrator:确定下一步、追问缺失条件、调度任务、处理失败和回滚。
|
||||||
|
- Plan Interpreter:解析图层、区域、比例和房间语义。
|
||||||
|
- Spatial Critic:检查动线、遮挡、焦点和基本几何约束。
|
||||||
|
- Style Director:维护 Style DNA,提出差异明确的风格方向。
|
||||||
|
- Layout Planner:操作结构化家具占位,不直接修改像素。
|
||||||
|
- Render Router:按任务特征选择图像模型,并生成控制输入。
|
||||||
|
- Aesthetic Reviewer:比较空间忠实度、配色、材质连续性和多视角一致性。
|
||||||
|
|
||||||
|
## 第一版人工确认点
|
||||||
|
|
||||||
|
1. 选择图纸中的目标区域。
|
||||||
|
2. 确认比例、墙、门窗和需要保留的家具。
|
||||||
|
3. 确认层高和关键开口高度。
|
||||||
|
4. 从三个风格方向中选择一个主方向。
|
||||||
|
5. 对高成本多视角生成进行确认。
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "zhuangxiu-studio",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "pnpm@11.9.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "pnpm --filter @zhuangxiu/web dev",
|
||||||
|
"build": "pnpm --filter @zhuangxiu/web build",
|
||||||
|
"lint": "pnpm --filter @zhuangxiu/web lint",
|
||||||
|
"typecheck": "pnpm --filter @zhuangxiu/web typecheck"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4338
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
|
||||||
|
allowBuilds:
|
||||||
|
sharp: true
|
||||||
|
unrs-resolver: true
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
COPY app ./app
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""AI interior style studio API."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""HTTP API routes."""
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.config import Settings, get_settings
|
||||||
|
from app.domain.models import CommandRequest, ProjectSnapshot, WorkflowDefinition
|
||||||
|
from app.domain.workflow import (
|
||||||
|
InvalidTransitionError,
|
||||||
|
WorkflowConflictError,
|
||||||
|
apply_command,
|
||||||
|
workflow_definition,
|
||||||
|
)
|
||||||
|
from app.integrations.model_router import ModelRouter
|
||||||
|
from app.integrations.storage import S3Storage
|
||||||
|
from app.repositories.memory import repository
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/v1")
|
||||||
|
|
||||||
|
|
||||||
|
class UploadRequest(BaseModel):
|
||||||
|
filename: str
|
||||||
|
content_type: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
def health(settings: Settings = Depends(get_settings)) -> dict:
|
||||||
|
router_state = ModelRouter(settings)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"environment": settings.app_env,
|
||||||
|
"integrations": {
|
||||||
|
"storage": S3Storage(settings).configured,
|
||||||
|
"baidu_ocr": bool(
|
||||||
|
settings.baidu_ocr_enabled
|
||||||
|
and settings.baidu_ocr_api_key
|
||||||
|
and settings.baidu_ocr_secret_key
|
||||||
|
),
|
||||||
|
"gpu": bool(settings.gpu_service_url and settings.gpu_service_token),
|
||||||
|
"langfuse": bool(
|
||||||
|
settings.langfuse_enabled
|
||||||
|
and settings.langfuse_public_key
|
||||||
|
and settings.langfuse_secret_key
|
||||||
|
),
|
||||||
|
"image_providers": [item.__dict__ for item in router_state.capabilities()],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/workflow", response_model=WorkflowDefinition)
|
||||||
|
def get_workflow() -> WorkflowDefinition:
|
||||||
|
return workflow_definition()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/{project_id}", response_model=ProjectSnapshot)
|
||||||
|
def get_project(project_id: str) -> ProjectSnapshot:
|
||||||
|
project = repository.get(project_id)
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found.")
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/projects/{project_id}/commands", response_model=ProjectSnapshot)
|
||||||
|
def execute_command(project_id: str, request: CommandRequest) -> ProjectSnapshot:
|
||||||
|
project = repository.get(project_id)
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found.")
|
||||||
|
try:
|
||||||
|
updated = apply_command(project, request)
|
||||||
|
except WorkflowConflictError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||||
|
except InvalidTransitionError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
|
return repository.save(updated)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/uploads/presign")
|
||||||
|
def create_upload(
|
||||||
|
request: UploadRequest,
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
) -> dict:
|
||||||
|
safe_name = request.filename.replace("/", "_").replace("\\", "_")
|
||||||
|
object_key = f"projects/pending/{uuid4()}/{safe_name}"
|
||||||
|
try:
|
||||||
|
upload = S3Storage(settings).presign_input_upload(object_key, request.content_type)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
|
||||||
|
return upload.__dict__
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
case_sensitive=False,
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
app_env: str = "development"
|
||||||
|
app_name: str = "zhuangxiu-api"
|
||||||
|
log_level: str = "INFO"
|
||||||
|
api_host: str = "0.0.0.0"
|
||||||
|
api_port: int = 8000
|
||||||
|
cors_origins: str = "http://localhost:3000"
|
||||||
|
|
||||||
|
database_url: str = "postgresql+psycopg://zhuangxiu:zhuangxiu@localhost:5432/zhuangxiu"
|
||||||
|
redis_url: str = "redis://localhost:6379/0"
|
||||||
|
|
||||||
|
s3_endpoint: str = ""
|
||||||
|
s3_public_endpoint: str = ""
|
||||||
|
s3_access_key_id: str = ""
|
||||||
|
s3_secret_access_key: str = ""
|
||||||
|
s3_region: str = "us-east-1"
|
||||||
|
s3_bucket_inputs: str = "renovation-inputs"
|
||||||
|
s3_bucket_derived: str = "renovation-derived"
|
||||||
|
s3_bucket_renders: str = "renovation-renders"
|
||||||
|
s3_force_path_style: bool = True
|
||||||
|
s3_presigned_url_ttl_seconds: int = 900
|
||||||
|
|
||||||
|
baidu_ocr_enabled: bool = False
|
||||||
|
baidu_ocr_api_key: str = ""
|
||||||
|
baidu_ocr_secret_key: str = ""
|
||||||
|
baidu_ocr_token_url: str = "https://aip.baidubce.com/oauth/2.0/token"
|
||||||
|
|
||||||
|
llm_default_provider: str = "openai"
|
||||||
|
openai_api_key: str = ""
|
||||||
|
openai_base_url: str = "https://api.openai.com/v1"
|
||||||
|
openai_text_model: str = "gpt-5"
|
||||||
|
openai_image_model: str = "gpt-image-2"
|
||||||
|
gemini_api_key: str = ""
|
||||||
|
gemini_model: str = "gemini-3-pro"
|
||||||
|
ark_api_key: str = ""
|
||||||
|
seedream_model_endpoint: str = ""
|
||||||
|
image_provider_priority: str = "seedream,openai,gemini"
|
||||||
|
|
||||||
|
gpu_service_url: str = "http://localhost:8100"
|
||||||
|
gpu_service_token: str = ""
|
||||||
|
|
||||||
|
jwt_secret: str = ""
|
||||||
|
session_secret: str = ""
|
||||||
|
app_encryption_key: str = ""
|
||||||
|
webhook_signing_secret: str = ""
|
||||||
|
|
||||||
|
langfuse_enabled: bool = False
|
||||||
|
langfuse_host: str = "http://localhost:3001"
|
||||||
|
langfuse_public_key: str = ""
|
||||||
|
langfuse_secret_key: str = ""
|
||||||
|
sentry_dsn: str = ""
|
||||||
|
sentry_environment: str = "development"
|
||||||
|
sentry_traces_sample_rate: float = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origin_list(self) -> list[str]:
|
||||||
|
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Domain models and workflow rules."""
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStage(StrEnum):
|
||||||
|
UPLOADED = "uploaded"
|
||||||
|
REGION_SELECTION = "region_selection"
|
||||||
|
PLAN_REVIEW = "plan_review"
|
||||||
|
BLOCKOUT = "blockout"
|
||||||
|
STYLE_BRIEF = "style_brief"
|
||||||
|
DIRECTION_SELECTION = "direction_selection"
|
||||||
|
RENDER_REVIEW = "render_review"
|
||||||
|
EDITING = "editing"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowCommand(StrEnum):
|
||||||
|
START_INGESTION = "start_ingestion"
|
||||||
|
SELECT_REGION = "select_region"
|
||||||
|
CONFIRM_PLAN = "confirm_plan"
|
||||||
|
BUILD_BLOCKOUT = "build_blockout"
|
||||||
|
SUBMIT_STYLE_BRIEF = "submit_style_brief"
|
||||||
|
SELECT_DIRECTION = "select_direction"
|
||||||
|
REQUEST_RENDER = "request_render"
|
||||||
|
APPLY_EDIT = "apply_edit"
|
||||||
|
COMPLETE_PROJECT = "complete_project"
|
||||||
|
RETRY = "retry"
|
||||||
|
|
||||||
|
|
||||||
|
class Point2D(BaseModel):
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
|
||||||
|
|
||||||
|
class PlanRegion(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
bounds: list[float] = Field(min_length=4, max_length=4)
|
||||||
|
recommended: bool = False
|
||||||
|
confidence: float = Field(ge=0, le=1)
|
||||||
|
|
||||||
|
|
||||||
|
class PlanLayer(BaseModel):
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
category: str
|
||||||
|
visible: bool = True
|
||||||
|
source_names: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PlanIssue(BaseModel):
|
||||||
|
id: str
|
||||||
|
kind: str
|
||||||
|
message: str
|
||||||
|
confidence: float = Field(ge=0, le=1)
|
||||||
|
resolved: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PlanState(BaseModel):
|
||||||
|
source_name: str
|
||||||
|
source_kind: str
|
||||||
|
page_count: int = 1
|
||||||
|
vector_based: bool = False
|
||||||
|
cad_layer_count: int = 0
|
||||||
|
regions: list[PlanRegion] = Field(default_factory=list)
|
||||||
|
selected_region_id: str | None = None
|
||||||
|
layers: list[PlanLayer] = Field(default_factory=list)
|
||||||
|
issues: list[PlanIssue] = Field(default_factory=list)
|
||||||
|
scale_mm_per_unit: float | None = None
|
||||||
|
ceiling_height_mm: int = 2800
|
||||||
|
|
||||||
|
|
||||||
|
class CameraView(BaseModel):
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
room: str
|
||||||
|
status: str = "draft"
|
||||||
|
|
||||||
|
|
||||||
|
class SceneState(BaseModel):
|
||||||
|
blockout_asset_key: str | None = None
|
||||||
|
cameras: list[CameraView] = Field(default_factory=list)
|
||||||
|
locked_object_ids: list[str] = Field(default_factory=list)
|
||||||
|
furniture_mode: str = "preserve_reference"
|
||||||
|
|
||||||
|
|
||||||
|
class ColorToken(BaseModel):
|
||||||
|
name: str
|
||||||
|
hex: str
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
class StyleState(BaseModel):
|
||||||
|
concept: str = "克制、自然、明亮的现代住宅"
|
||||||
|
keywords: list[str] = Field(default_factory=lambda: ["轻盈", "自然材质", "留白"])
|
||||||
|
palette: list[ColorToken] = Field(default_factory=list)
|
||||||
|
materials: list[str] = Field(default_factory=list)
|
||||||
|
lighting: str = "低对比度的自然光与柔和间接照明"
|
||||||
|
forms: str = "低矮、水平延展、少量圆角"
|
||||||
|
density: str = "适度留白"
|
||||||
|
avoid: list[str] = Field(default_factory=list)
|
||||||
|
locked_decisions: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectSnapshot(BaseModel):
|
||||||
|
project_id: str
|
||||||
|
name: str
|
||||||
|
stage: WorkflowStage
|
||||||
|
revision: int = 1
|
||||||
|
plan: PlanState
|
||||||
|
scene: SceneState = Field(default_factory=SceneState)
|
||||||
|
style: StyleState = Field(default_factory=StyleState)
|
||||||
|
available_commands: list[WorkflowCommand] = Field(default_factory=list)
|
||||||
|
last_stable_stage: WorkflowStage | None = None
|
||||||
|
failure_reason: str | None = None
|
||||||
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
|
||||||
|
class CommandRequest(BaseModel):
|
||||||
|
command: WorkflowCommand
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStageDefinition(BaseModel):
|
||||||
|
id: WorkflowStage
|
||||||
|
label: str
|
||||||
|
purpose: str
|
||||||
|
human_confirmation: bool
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowDefinition(BaseModel):
|
||||||
|
stages: list[WorkflowStageDefinition]
|
||||||
|
transitions: dict[str, list[str]]
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from app.domain.models import (
|
||||||
|
CommandRequest,
|
||||||
|
ProjectSnapshot,
|
||||||
|
WorkflowCommand,
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowStage,
|
||||||
|
WorkflowStageDefinition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowConflictError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTransitionError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
TRANSITIONS: dict[WorkflowStage, dict[WorkflowCommand, WorkflowStage]] = {
|
||||||
|
WorkflowStage.UPLOADED: {
|
||||||
|
WorkflowCommand.START_INGESTION: WorkflowStage.REGION_SELECTION,
|
||||||
|
},
|
||||||
|
WorkflowStage.REGION_SELECTION: {
|
||||||
|
WorkflowCommand.SELECT_REGION: WorkflowStage.PLAN_REVIEW,
|
||||||
|
},
|
||||||
|
WorkflowStage.PLAN_REVIEW: {
|
||||||
|
WorkflowCommand.CONFIRM_PLAN: WorkflowStage.BLOCKOUT,
|
||||||
|
},
|
||||||
|
WorkflowStage.BLOCKOUT: {
|
||||||
|
WorkflowCommand.BUILD_BLOCKOUT: WorkflowStage.STYLE_BRIEF,
|
||||||
|
},
|
||||||
|
WorkflowStage.STYLE_BRIEF: {
|
||||||
|
WorkflowCommand.SUBMIT_STYLE_BRIEF: WorkflowStage.DIRECTION_SELECTION,
|
||||||
|
},
|
||||||
|
WorkflowStage.DIRECTION_SELECTION: {
|
||||||
|
WorkflowCommand.SELECT_DIRECTION: WorkflowStage.RENDER_REVIEW,
|
||||||
|
},
|
||||||
|
WorkflowStage.RENDER_REVIEW: {
|
||||||
|
WorkflowCommand.REQUEST_RENDER: WorkflowStage.EDITING,
|
||||||
|
WorkflowCommand.COMPLETE_PROJECT: WorkflowStage.COMPLETED,
|
||||||
|
},
|
||||||
|
WorkflowStage.EDITING: {
|
||||||
|
WorkflowCommand.APPLY_EDIT: WorkflowStage.EDITING,
|
||||||
|
WorkflowCommand.REQUEST_RENDER: WorkflowStage.EDITING,
|
||||||
|
WorkflowCommand.COMPLETE_PROJECT: WorkflowStage.COMPLETED,
|
||||||
|
},
|
||||||
|
WorkflowStage.FAILED: {
|
||||||
|
WorkflowCommand.RETRY: WorkflowStage.PLAN_REVIEW,
|
||||||
|
},
|
||||||
|
WorkflowStage.COMPLETED: {
|
||||||
|
WorkflowCommand.APPLY_EDIT: WorkflowStage.EDITING,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def available_commands(stage: WorkflowStage) -> list[WorkflowCommand]:
|
||||||
|
return list(TRANSITIONS.get(stage, {}).keys())
|
||||||
|
|
||||||
|
|
||||||
|
def apply_command(project: ProjectSnapshot, request: CommandRequest) -> ProjectSnapshot:
|
||||||
|
if request.expected_revision != project.revision:
|
||||||
|
raise WorkflowConflictError(
|
||||||
|
f"Expected revision {request.expected_revision}, current revision is {project.revision}."
|
||||||
|
)
|
||||||
|
|
||||||
|
target = TRANSITIONS.get(project.stage, {}).get(request.command)
|
||||||
|
if target is None:
|
||||||
|
raise InvalidTransitionError(
|
||||||
|
f"Command '{request.command}' is not allowed while project is in '{project.stage}'."
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = deepcopy(project)
|
||||||
|
updated.last_stable_stage = project.stage
|
||||||
|
updated.stage = target
|
||||||
|
updated.revision += 1
|
||||||
|
updated.updated_at = datetime.now(UTC)
|
||||||
|
updated.failure_reason = None
|
||||||
|
|
||||||
|
if request.command == WorkflowCommand.SELECT_REGION:
|
||||||
|
updated.plan.selected_region_id = request.payload.get("region_id")
|
||||||
|
elif request.command == WorkflowCommand.CONFIRM_PLAN:
|
||||||
|
updated.plan.ceiling_height_mm = int(
|
||||||
|
request.payload.get("ceiling_height_mm", updated.plan.ceiling_height_mm)
|
||||||
|
)
|
||||||
|
elif request.command == WorkflowCommand.SUBMIT_STYLE_BRIEF:
|
||||||
|
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]))
|
||||||
|
|
||||||
|
updated.available_commands = available_commands(updated.stage)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def workflow_definition() -> WorkflowDefinition:
|
||||||
|
labels = {
|
||||||
|
WorkflowStage.UPLOADED: ("文件已上传", "验证文件并准备解析", False),
|
||||||
|
WorkflowStage.REGION_SELECTION: ("选择户型", "从复杂图纸中选择目标平面区域", True),
|
||||||
|
WorkflowStage.PLAN_REVIEW: ("确认结构", "检查墙、门窗、比例和保留家具", True),
|
||||||
|
WorkflowStage.BLOCKOUT: ("生成白模", "建立空间骨架、家具占位和相机", False),
|
||||||
|
WorkflowStage.STYLE_BRIEF: ("明确风格", "形成结构化 Style DNA", True),
|
||||||
|
WorkflowStage.DIRECTION_SELECTION: ("选择方向", "比较并锁定主设计方向", True),
|
||||||
|
WorkflowStage.RENDER_REVIEW: ("审阅效果", "检查空间忠实度与审美一致性", True),
|
||||||
|
WorkflowStage.EDITING: ("多轮修改", "通过结构化操作局部修改", True),
|
||||||
|
WorkflowStage.COMPLETED: ("方案完成", "导出概念方案与版本记录", False),
|
||||||
|
WorkflowStage.FAILED: ("需要处理", "展示错误并从稳定阶段恢复", True),
|
||||||
|
}
|
||||||
|
return WorkflowDefinition(
|
||||||
|
stages=[
|
||||||
|
WorkflowStageDefinition(
|
||||||
|
id=stage,
|
||||||
|
label=labels[stage][0],
|
||||||
|
purpose=labels[stage][1],
|
||||||
|
human_confirmation=labels[stage][2],
|
||||||
|
)
|
||||||
|
for stage in WorkflowStage
|
||||||
|
],
|
||||||
|
transitions={
|
||||||
|
stage.value: [command.value for command in commands]
|
||||||
|
for stage, commands in TRANSITIONS.items()
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""External service adapters."""
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def _configured(value: str) -> bool:
|
||||||
|
return bool(value) and not value.startswith("replace_with_")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProviderCapability:
|
||||||
|
provider: str
|
||||||
|
configured: bool
|
||||||
|
strengths: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
class ModelRouter:
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
|
||||||
|
def capabilities(self) -> list[ProviderCapability]:
|
||||||
|
return [
|
||||||
|
ProviderCapability(
|
||||||
|
provider="seedream",
|
||||||
|
configured=_configured(self.settings.ark_api_key)
|
||||||
|
and bool(self.settings.seedream_model_endpoint),
|
||||||
|
strengths=("中文风格指令", "快速方向探索"),
|
||||||
|
),
|
||||||
|
ProviderCapability(
|
||||||
|
provider="openai",
|
||||||
|
configured=_configured(self.settings.openai_api_key),
|
||||||
|
strengths=("局部编辑", "多轮一致性", "遮罩修改"),
|
||||||
|
),
|
||||||
|
ProviderCapability(
|
||||||
|
provider="gemini",
|
||||||
|
configured=_configured(self.settings.gemini_api_key),
|
||||||
|
strengths=("多参考图理解", "复杂视觉指令"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def choose_image_provider(self, task: str) -> str:
|
||||||
|
configured = {item.provider for item in self.capabilities() if item.configured}
|
||||||
|
priorities = [item.strip() for item in self.settings.image_provider_priority.split(",")]
|
||||||
|
if task == "masked_edit" and "openai" in configured:
|
||||||
|
return "openai"
|
||||||
|
for provider in priorities:
|
||||||
|
if provider in configured:
|
||||||
|
return provider
|
||||||
|
raise RuntimeError("No image provider is configured.")
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import base64
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class BaiduOcrAdapter:
|
||||||
|
OCR_URL = "https://aip.baidubce.com/rest/2.0/ocr/v1/accurate_basic"
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
|
||||||
|
@property
|
||||||
|
def configured(self) -> bool:
|
||||||
|
return bool(
|
||||||
|
self.settings.baidu_ocr_enabled
|
||||||
|
and self.settings.baidu_ocr_api_key
|
||||||
|
and self.settings.baidu_ocr_secret_key
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _access_token(self) -> str:
|
||||||
|
async with httpx.AsyncClient(timeout=20) as client:
|
||||||
|
response = await client.post(
|
||||||
|
self.settings.baidu_ocr_token_url,
|
||||||
|
params={
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": self.settings.baidu_ocr_api_key,
|
||||||
|
"client_secret": self.settings.baidu_ocr_secret_key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()["access_token"]
|
||||||
|
|
||||||
|
async def recognize(self, image_bytes: bytes) -> list[dict]:
|
||||||
|
if not self.configured:
|
||||||
|
raise RuntimeError("Baidu OCR is not configured or enabled.")
|
||||||
|
token = await self._access_token()
|
||||||
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
|
response = await client.post(
|
||||||
|
self.OCR_URL,
|
||||||
|
params={"access_token": token},
|
||||||
|
data={
|
||||||
|
"image": base64.b64encode(image_bytes).decode("ascii"),
|
||||||
|
"detect_direction": "true",
|
||||||
|
"paragraph": "false",
|
||||||
|
},
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json().get("words_result", [])
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.client import Config
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PresignedUpload:
|
||||||
|
method: str
|
||||||
|
url: str
|
||||||
|
object_key: str
|
||||||
|
expires_in: int
|
||||||
|
|
||||||
|
|
||||||
|
class S3Storage:
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
|
||||||
|
@property
|
||||||
|
def configured(self) -> bool:
|
||||||
|
values = (
|
||||||
|
self.settings.s3_endpoint,
|
||||||
|
self.settings.s3_access_key_id,
|
||||||
|
self.settings.s3_secret_access_key,
|
||||||
|
)
|
||||||
|
return all(values) and not any(value.startswith("replace_with_") for value in values)
|
||||||
|
|
||||||
|
def _client(self, public: bool = False):
|
||||||
|
endpoint = (
|
||||||
|
self.settings.s3_public_endpoint
|
||||||
|
if public and self.settings.s3_public_endpoint
|
||||||
|
else self.settings.s3_endpoint
|
||||||
|
)
|
||||||
|
return boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=endpoint,
|
||||||
|
aws_access_key_id=self.settings.s3_access_key_id,
|
||||||
|
aws_secret_access_key=self.settings.s3_secret_access_key,
|
||||||
|
region_name=self.settings.s3_region,
|
||||||
|
config=Config(
|
||||||
|
signature_version="s3v4",
|
||||||
|
s3={"addressing_style": "path" if self.settings.s3_force_path_style else "auto"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def presign_input_upload(self, object_key: str, content_type: str) -> PresignedUpload:
|
||||||
|
if not self.configured:
|
||||||
|
raise RuntimeError("MinIO/S3 is not configured.")
|
||||||
|
url = self._client(public=True).generate_presigned_url(
|
||||||
|
"put_object",
|
||||||
|
Params={
|
||||||
|
"Bucket": self.settings.s3_bucket_inputs,
|
||||||
|
"Key": object_key,
|
||||||
|
"ContentType": content_type,
|
||||||
|
},
|
||||||
|
ExpiresIn=self.settings.s3_presigned_url_ttl_seconds,
|
||||||
|
)
|
||||||
|
return PresignedUpload(
|
||||||
|
method="PUT",
|
||||||
|
url=url,
|
||||||
|
object_key=object_key,
|
||||||
|
expires_in=self.settings.s3_presigned_url_ttl_seconds,
|
||||||
|
)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.api.routes import router
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="AI 空间风格工作台 API",
|
||||||
|
version="0.1.0",
|
||||||
|
description="户型清洗、3D 白模、Style DNA 与多轮编辑的工作流调度接口。",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origin_list,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
app.include_router(router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def root() -> dict[str, str]:
|
||||||
|
return {"service": settings.app_name, "docs": "/docs"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Persistence ports and development repositories."""
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
from app.domain.models import (
|
||||||
|
CameraView,
|
||||||
|
ColorToken,
|
||||||
|
PlanIssue,
|
||||||
|
PlanLayer,
|
||||||
|
PlanRegion,
|
||||||
|
PlanState,
|
||||||
|
ProjectSnapshot,
|
||||||
|
SceneState,
|
||||||
|
StyleState,
|
||||||
|
WorkflowStage,
|
||||||
|
)
|
||||||
|
from app.domain.workflow import available_commands
|
||||||
|
|
||||||
|
|
||||||
|
def create_demo_project() -> ProjectSnapshot:
|
||||||
|
stage = WorkflowStage.PLAN_REVIEW
|
||||||
|
project = ProjectSnapshot(
|
||||||
|
project_id="demo-apartment",
|
||||||
|
name="11-2-104 住宅概念方案",
|
||||||
|
stage=stage,
|
||||||
|
revision=3,
|
||||||
|
plan=PlanState(
|
||||||
|
source_name="11-2-104-模型.pdf",
|
||||||
|
source_kind="vector_pdf",
|
||||||
|
vector_based=True,
|
||||||
|
cad_layer_count=43,
|
||||||
|
regions=[
|
||||||
|
PlanRegion(
|
||||||
|
id="residence-upper",
|
||||||
|
name="上方住宅户型",
|
||||||
|
bounds=[0.08, 0.05, 0.92, 0.58],
|
||||||
|
recommended=True,
|
||||||
|
confidence=0.97,
|
||||||
|
),
|
||||||
|
PlanRegion(
|
||||||
|
id="common-lower",
|
||||||
|
name="下方公共区域",
|
||||||
|
bounds=[0.12, 0.61, 0.88, 0.94],
|
||||||
|
confidence=0.91,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected_region_id="residence-upper",
|
||||||
|
layers=[
|
||||||
|
PlanLayer(
|
||||||
|
id="architecture",
|
||||||
|
label="建筑结构",
|
||||||
|
category="structure",
|
||||||
|
source_names=["A-墙线", "WALL", "S-COLUMN"],
|
||||||
|
),
|
||||||
|
PlanLayer(
|
||||||
|
id="openings",
|
||||||
|
label="门窗",
|
||||||
|
category="openings",
|
||||||
|
source_names=["WINDOW", "A-普通门窗", "A-防火门窗"],
|
||||||
|
),
|
||||||
|
PlanLayer(
|
||||||
|
id="furniture",
|
||||||
|
label="原家具",
|
||||||
|
category="furniture",
|
||||||
|
source_names=["FF-FURN", "C-Chen_活动家具"],
|
||||||
|
),
|
||||||
|
PlanLayer(
|
||||||
|
id="dimensions",
|
||||||
|
label="尺寸标注",
|
||||||
|
category="annotation",
|
||||||
|
visible=False,
|
||||||
|
source_names=["B-标注", "DIM_SYMB"],
|
||||||
|
),
|
||||||
|
PlanLayer(
|
||||||
|
id="labels",
|
||||||
|
label="房间文字",
|
||||||
|
category="annotation",
|
||||||
|
visible=False,
|
||||||
|
source_names=["A-房间名称文字", "W-文字"],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
issues=[
|
||||||
|
PlanIssue(
|
||||||
|
id="scale-check",
|
||||||
|
kind="scale",
|
||||||
|
message="多个尺寸标注需要交叉确认比例",
|
||||||
|
confidence=0.82,
|
||||||
|
),
|
||||||
|
PlanIssue(
|
||||||
|
id="furniture-intent",
|
||||||
|
kind="intent",
|
||||||
|
message="请确认原家具是保留布局、参考方案还是噪声",
|
||||||
|
confidence=1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ceiling_height_mm=2800,
|
||||||
|
),
|
||||||
|
scene=SceneState(
|
||||||
|
cameras=[
|
||||||
|
CameraView(id="living-entry", label="客厅入口", room="客厅"),
|
||||||
|
CameraView(id="living-diagonal", label="客厅对角", room="客厅"),
|
||||||
|
CameraView(id="dining-focus", label="餐厨主景", room="餐厅"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
style=StyleState(
|
||||||
|
concept="温暖、克制、带自然材质感的现代住宅",
|
||||||
|
keywords=["清透", "低饱和", "自然纹理", "松弛"],
|
||||||
|
palette=[
|
||||||
|
ColorToken(name="雾白", hex="#E8E9E4", role="base"),
|
||||||
|
ColorToken(name="浅橡木", hex="#B9A68A", role="secondary"),
|
||||||
|
ColorToken(name="松针绿", hex="#50665C", role="accent"),
|
||||||
|
],
|
||||||
|
materials=["哑光涂料", "浅橡木", "亚麻", "浅色洞石"],
|
||||||
|
avoid=["大面积冷灰", "高亮岩板", "过量灯带"],
|
||||||
|
locked_decisions=["保留主要窗洞", "不改变客餐厅关系"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
project.available_commands = available_commands(stage)
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryProjectRepository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
demo = create_demo_project()
|
||||||
|
self._items = {demo.project_id: demo}
|
||||||
|
|
||||||
|
def get(self, project_id: str) -> ProjectSnapshot | None:
|
||||||
|
return self._items.get(project_id)
|
||||||
|
|
||||||
|
def save(self, project: ProjectSnapshot) -> ProjectSnapshot:
|
||||||
|
self._items[project.project_id] = project
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
repository = InMemoryProjectRepository()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=75", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "zhuangxiu-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Workflow orchestrator for the AI interior style studio"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"boto3>=1.35,<2",
|
||||||
|
"fastapi>=0.115,<1",
|
||||||
|
"httpx>=0.28,<1",
|
||||||
|
"pydantic>=2.10,<3",
|
||||||
|
"pydantic-settings>=2.7,<3",
|
||||||
|
"python-multipart>=0.0.20,<1",
|
||||||
|
"uvicorn[standard]>=0.34,<1"
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.3,<9",
|
||||||
|
"pytest-asyncio>=0.25,<1"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["app*"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["."]
|
||||||
|
testpaths = ["tests"]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def transport() -> httpx.ASGITransport:
|
||||||
|
return httpx.ASGITransport(app=app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health(transport: httpx.ASGITransport) -> None:
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get("/v1/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_demo_project_contract(transport: httpx.ASGITransport) -> None:
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get("/v1/projects/demo-apartment")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["stage"] == "plan_review"
|
||||||
|
assert body["plan"]["cad_layer_count"] == 43
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domain.models import CommandRequest, WorkflowCommand, WorkflowStage
|
||||||
|
from app.domain.workflow import InvalidTransitionError, WorkflowConflictError, apply_command
|
||||||
|
from app.repositories.memory import create_demo_project
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_plan_advances_revision_and_stage() -> None:
|
||||||
|
project = create_demo_project()
|
||||||
|
updated = apply_command(
|
||||||
|
project,
|
||||||
|
CommandRequest(
|
||||||
|
command=WorkflowCommand.CONFIRM_PLAN,
|
||||||
|
expected_revision=project.revision,
|
||||||
|
payload={"ceiling_height_mm": 2900},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated.stage == WorkflowStage.BLOCKOUT
|
||||||
|
assert updated.revision == project.revision + 1
|
||||||
|
assert updated.plan.ceiling_height_mm == 2900
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_revision_is_rejected() -> None:
|
||||||
|
project = create_demo_project()
|
||||||
|
with pytest.raises(WorkflowConflictError):
|
||||||
|
apply_command(
|
||||||
|
project,
|
||||||
|
CommandRequest(
|
||||||
|
command=WorkflowCommand.CONFIRM_PLAN,
|
||||||
|
expected_revision=project.revision - 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_command_is_rejected() -> None:
|
||||||
|
project = create_demo_project()
|
||||||
|
with pytest.raises(InvalidTransitionError):
|
||||||
|
apply_command(
|
||||||
|
project,
|
||||||
|
CommandRequest(
|
||||||
|
command=WorkflowCommand.REQUEST_RENDER,
|
||||||
|
expected_revision=project.revision,
|
||||||
|
),
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user