From 6a15217d5521f3f23b4179aba09c373dc40b1b9d Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 1 Aug 2026 21:07:17 +0800 Subject: [PATCH] feat: scaffold agentic interior design workflow --- .editorconfig | 11 + .env.example | 149 + .gitignore | 4 +- README.md | 39 + apps/web/Dockerfile | 24 + apps/web/app/globals.css | 945 ++++ apps/web/app/layout.tsx | 17 + apps/web/app/page.tsx | 5 + apps/web/components/workflow-studio.tsx | 333 ++ apps/web/eslint.config.mjs | 9 + apps/web/lib/demo.ts | 64 + apps/web/next-env.d.ts | 6 + apps/web/next.config.ts | 8 + apps/web/package.json | 28 + apps/web/public/sample-plan.png | Bin 0 -> 133481 bytes apps/web/tsconfig.json | 41 + apps/web/types/workflow.ts | 39 + contracts/workflow-contract.md | 31 + docker-compose.yml | 60 + docs/architecture.md | 65 + docs/workflow.md | 49 + package.json | 12 + pnpm-lock.yaml | 4338 +++++++++++++++++ pnpm-workspace.yaml | 6 + services/api/Dockerfile | 15 + services/api/app/__init__.py | 1 + services/api/app/api/__init__.py | 1 + services/api/app/api/routes.py | 88 + services/api/app/config.py | 74 + services/api/app/domain/__init__.py | 1 + services/api/app/domain/models.py | 138 + services/api/app/domain/workflow.py | 127 + services/api/app/integrations/__init__.py | 1 + services/api/app/integrations/model_router.py | 49 + services/api/app/integrations/ocr.py | 51 + services/api/app/integrations/storage.py | 65 + services/api/app/main.py | 27 + services/api/app/repositories/__init__.py | 1 + services/api/app/repositories/memory.py | 131 + services/api/pyproject.toml | 32 + services/api/tests/test_api.py | 27 + services/api/tests/test_workflow.py | 45 + 42 files changed, 7156 insertions(+), 1 deletion(-) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 README.md create mode 100644 apps/web/Dockerfile create mode 100644 apps/web/app/globals.css create mode 100644 apps/web/app/layout.tsx create mode 100644 apps/web/app/page.tsx create mode 100644 apps/web/components/workflow-studio.tsx create mode 100644 apps/web/eslint.config.mjs create mode 100644 apps/web/lib/demo.ts create mode 100644 apps/web/next-env.d.ts create mode 100644 apps/web/next.config.ts create mode 100644 apps/web/package.json create mode 100644 apps/web/public/sample-plan.png create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/types/workflow.ts create mode 100644 contracts/workflow-contract.md create mode 100644 docker-compose.yml create mode 100644 docs/architecture.md create mode 100644 docs/workflow.md create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 services/api/Dockerfile create mode 100644 services/api/app/__init__.py create mode 100644 services/api/app/api/__init__.py create mode 100644 services/api/app/api/routes.py create mode 100644 services/api/app/config.py create mode 100644 services/api/app/domain/__init__.py create mode 100644 services/api/app/domain/models.py create mode 100644 services/api/app/domain/workflow.py create mode 100644 services/api/app/integrations/__init__.py create mode 100644 services/api/app/integrations/model_router.py create mode 100644 services/api/app/integrations/ocr.py create mode 100644 services/api/app/integrations/storage.py create mode 100644 services/api/app/main.py create mode 100644 services/api/app/repositories/__init__.py create mode 100644 services/api/app/repositories/memory.py create mode 100644 services/api/pyproject.toml create mode 100644 services/api/tests/test_api.py create mode 100644 services/api/tests/test_workflow.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..de99aa0 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a05dd69 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 7c34ae8..fb7820c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ temp/ # Python __pycache__/ *.py[cod] +*.egg-info/ .venv/ venv/ .pytest_cache/ @@ -20,6 +21,8 @@ venv/ # Node.js node_modules/ +.pnpm-store/ +*.tsbuildinfo .next/ dist/ build/ @@ -30,4 +33,3 @@ coverage/ .vscode/ .DS_Store Thumbs.db - diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e186a3 --- /dev/null +++ b/README.md @@ -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)。 diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..686534c --- /dev/null +++ b/apps/web/Dockerfile @@ -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"] diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..6a233bb --- /dev/null +++ b/apps/web/app/globals.css @@ -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; + } +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..2e0d52f --- /dev/null +++ b/apps/web/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx new file mode 100644 index 0000000..9b37280 --- /dev/null +++ b/apps/web/app/page.tsx @@ -0,0 +1,5 @@ +import { WorkflowStudio } from "@/components/workflow-studio"; + +export default function Home() { + return ; +} diff --git a/apps/web/components/workflow-studio.tsx b/apps/web/components/workflow-studio.tsx new file mode 100644 index 0000000..94fe7c5 --- /dev/null +++ b/apps/web/components/workflow-studio.tsx @@ -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(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) { + 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 ( + +
+
+
+ +
+

空间风格工作台

+

11-2-104 住宅概念方案

+
+
+ +
+ 已保存 + + +
+
+ +
+ + +
+ +
+ + + 户型清洗 + + + 3D 白模 + + + 效果图 + + + +
+ + + + + 适应画布 + + +
+
+ + +
+
+
+ 上方住宅户型 + 矢量 PDF · 43 个 CAD 图层 +
+
+ 从真实 PDF 中分离出的住宅建筑结构平面图 + +
+ 已分离建筑结构 + 比例待交叉校准 +
+
+ + +
+ + + + +

白模将在结构确认后生成

+

系统会拉伸墙体、门窗和柱,并自动建立三个室内相机。

+
+ + + +

先选定风格方向

+

低成本方向图通过后,再生成完整多视角效果图。

+
+ + +
+
+ {messages.slice(-3).map((message) => ( +
+ {message.role === "assistant" ? ( + + ) : null} +
+ {message.meta ? {message.meta} : null} +

{message.body}

+
+
+ ))} +
+
+ +
+ setDraft(event.target.value)} + placeholder="例如:保留床和窗的位置,客厅更松弛,不要冷灰" + /> + +
+
+
+
+ +