From 4ecab597f445e48b256250042e4a4142685a4ed2 Mon Sep 17 00:00:00 2001 From: jimi <1847930177@qq.com> Date: Thu, 12 Mar 2026 13:47:42 +0800 Subject: [PATCH] feat: expand AI workflow support and refresh docs --- AdminTool/README.md | 337 +--- PltService/README.md | 123 +- README.md | 134 ++ Server/.env.example | 40 + Server/README.md | 164 +- Server/alembic/env.py | 9 +- Server/app/api/v1/ai_chat.py | 928 ++++------ Server/app/api/v1/ai_identify.py | 245 +++ Server/app/api/v1/ai_llm.py | 868 +++++---- Server/app/api/v1/ai_pattern.py | 476 +++++ Server/app/api/v1/ai_skills.py | 636 +++++++ Server/app/api/v1/ai_tools.py | 1570 +++++++++++------ Server/app/api/v1/logs.py | 72 +- Server/app/api/v1/user_profile.py | 51 + Server/app/core/config.py | 69 +- Server/app/db.py | 33 +- Server/app/main.py | 85 +- Server/app/models/chat.py | 2 + Server/app/models/user.py | 11 + Server/migrations/README.md | 28 + Server/requirements.txt | 1 + Server/test_identify_prompt.py | 329 ++++ docs/AI智能套图方案v2.md | 405 +++++ docs/ai/ark-seedream-5-update.md | 44 + docs/ai/openclaw-skill-adaptation.md | 53 + AI改图-双图.py => temp/AI改图-双图.py | 0 .../默认方案-POLO衫长袖-A料-已旋转.plt | 0 .../默认方案-POLO衫长袖-A料-标准.plt | 0 28 files changed, 4806 insertions(+), 1907 deletions(-) create mode 100644 README.md create mode 100644 Server/.env.example create mode 100644 Server/app/api/v1/ai_identify.py create mode 100644 Server/app/api/v1/ai_pattern.py create mode 100644 Server/app/api/v1/ai_skills.py create mode 100644 Server/migrations/README.md create mode 100644 Server/test_identify_prompt.py create mode 100644 docs/AI智能套图方案v2.md create mode 100644 docs/ai/ark-seedream-5-update.md create mode 100644 docs/ai/openclaw-skill-adaptation.md rename AI改图-双图.py => temp/AI改图-双图.py (100%) rename 默认方案-POLO衫长袖-A料-已旋转.plt => temp/默认方案-POLO衫长袖-A料-已旋转.plt (100%) rename 默认方案-POLO衫长袖-A料-标准.plt => temp/默认方案-POLO衫长袖-A料-标准.plt (100%) diff --git a/AdminTool/README.md b/AdminTool/README.md index a8eabbc..d8473b0 100644 --- a/AdminTool/README.md +++ b/AdminTool/README.md @@ -1,86 +1,60 @@ -# DesignerCEP AdminTool 使用说明 +# AdminTool(管理与部署工具) -## 📦 安装依赖 +本目录是一个基于 PyQt5 的图形化管理工具,包含用户管理、版本管理、后端部署与回滚等能力。 + +## 1. 当前文件与入口 + +- `admin_gui.py`:完整 GUI 主程序 +- `deploy_tool.py`:快速启动器(直接切到“自动部署”页) +- `deploy_config.json`:部署配置(主机、账号、路径等) + +说明:当前仓库不存在 `auto_deploy_core.py`,旧文档已失效。 + +## 2. 安装与启动 ```bash cd AdminTool pip install -r requirements.txt ``` ---- - -## 🚀 一、自动化部署脚本 (auto_deploy_core.py) - -### 功能说明 - -自动化完成以下所有步骤: -1. ✅ 构建前端(Shell + Core) -2. ✅ 打包 Shell.zip(供 CEP 扩展下载) -3. ✅ 上传到服务器(SSH/SFTP) -4. ✅ 更新 MySQL 数据库版本号 -5. ✅ 清除本地缓存(测试用) - -### 使用方法 - -#### 1. 首次使用 - 配置服务器 +启动完整工具: ```bash -python auto_deploy_core.py --version 1.0.6 --setup +python admin_gui.py ``` -会提示你输入: -- 服务器地址 -- SSH 端口、用户名、密码 -- 远程路径 -- MySQL 地址、端口、用户名、密码、数据库名 - -配置会保存到 `deploy_config.json`,下次直接使用。 - -#### 2. 仅构建(不部署) +仅进入部署页: ```bash -python auto_deploy_core.py --version 1.0.6 +python deploy_tool.py ``` -只在本地构建 Shell 和 Core,不上传到服务器。 +## 3. 主要功能(基于当前代码) -#### 3. 构建并部署 +### 3.1 后端 API 管理 -```bash -python auto_deploy_core.py --version 1.0.6 --deploy -``` +- 连接后台(默认 API: `https://backend.aidg168.uk/api/v1`) +- 用户组管理 +- 用户权限管理 +- 版本归档上传与分配 -构建并自动上传到服务器: -- Shell 在线登录页 → `/static/shell/` -- Core 核心应用 → `/static/core/1.0.6/` -- Shell.zip 下载包 → `/static/downloads/shell-1.0.6.zip` +### 3.2 服务器部署(SSH) -#### 4. 构建、部署并更新数据库 +- 通过 SSH/SFTP 上传后端代码到远程 `Server` 目录 +- 远程执行 `docker-compose up -d --build` +- 查看容器状态、日志、健康检查 +- 同步数据库结构(容器内执行脚本) -```bash -python auto_deploy_core.py --version 1.0.6 --deploy --update-db -``` +### 3.3 后端版本历史 -完整流程:构建 → 上传 → 更新 MySQL 数据库版本号。 +- 记录部署版本 +- 标记当前版本 +- 回滚到历史版本 +- 删除历史版本 -**数据库更新 SQL:** -```sql -UPDATE plugin_groups SET current_version = '1.0.6' WHERE id = 1; -``` +## 4. 配置文件 -#### 5. 其他选项 - -```bash -# 跳过清除缓存 -python auto_deploy_core.py --version 1.0.6 --deploy --skip-clean - -# 重新配置服务器 -python auto_deploy_core.py --version 1.0.6 --setup -``` - -### 配置文件示例 - -`deploy_config.json`: +工具读取 `deploy_config.json`。典型字段如下: ```json { @@ -88,243 +62,24 @@ python auto_deploy_core.py --version 1.0.6 --setup "port": "22", "username": "root", "password": "your-password", - "remote_path": "/var/www/DesignerCEP/Server/static", - "mysql": { - "host": "localhost", - "port": "3306", - "username": "root", - "password": "your-mysql-password", - "database": "designer_cep", - "table": "plugin_groups" - } + "remote_path": "/root/Server" } ``` ---- +如果本地没有该文件,可先在 GUI 中填写并保存配置。 -## 🎨 二、图形化管理工具 (admin_gui.py) +## 5. 依赖 -### 功能说明 +`requirements.txt`: -提供可视化界面管理: -- 用户组管理 -- 用户权限管理 -- 版本上传和分配 -- **自动化部署**(新增) +- `PyQt5` +- `requests` +- `paramiko` +- `PyQt-Fluent-Widgets` -### 使用方法 +## 6. 使用建议 -```bash -python admin_gui.py -``` - -### 主要功能 - -#### 1. 组与用户管理 - -- 创建用户组 -- 修改组备注 -- 查看组内用户 -- 移动用户到其他组 -- 修改用户权限 - -#### 2. 发布与上传 - -- 上传 ZIP 版本文件 -- 查看历史版本 -- 分配版本给用户组 - -#### 3. 自动化部署 ⭐ (新增) - -完全图形化的部署流程: - -**3.1 服务器配置** -- 服务器地址、SSH 端口 -- 用户名、密码 -- 远程路径 -- 保存/加载配置 -- 测试 SSH 连接 - -**3.2 本地构建配置** -- 项目根目录(自动检测) -- 版本号 - -**3.3 部署选项** -- ☑️ 部署 Shell(在线登录页) -- ☑️ 部署 Core(核心应用) -- ☑️ 打包 Shell 为 .zip -- ☑️ 构建前端(npm run build) - -**3.4 一键部署** -- 点击 "🚀 开始部署" 按钮 -- 实时查看部署日志 -- 进度条显示当前进度 -- 部署完成后显示访问地址 - ---- - -## 📖 三、部署流程说明 - -### 完整部署流程 - -``` -1. 构建前端 - └─> npm run build (Designer/) - └─> 生成 dist/Shell/ 和 dist/Designer/ - -2. 打包 Shell.zip - └─> 压缩 dist/Shell/ → shell-{version}.zip - -3. 连接服务器 (SSH) - └─> 使用配置的服务器信息 - -4. 创建远程目录 - └─> /static/shell/ - └─> /static/core/{version}/ - └─> /static/downloads/ - -5. 上传文件 - └─> Shell → /static/shell/ (在线登录页) - └─> Core → /static/core/{version}/ (核心应用) - └─> shell-{version}.zip → /static/downloads/ (CEP 扩展下载) - -6. 更新数据库 (MySQL) - └─> UPDATE plugin_groups SET current_version = '{version}' - -7. 完成! - └─> 显示访问地址 -``` - -### 服务器目录结构 - -``` -/var/www/DesignerCEP/Server/static/ -├── shell/ # Shell 在线登录页 -│ ├── index.html -│ └── assets/ -├── downloads/ # 下载文件 -│ └── shell-1.0.6.zip -└── core/ # Core 核心应用 - ├── 1.0.5/ - └── 1.0.6/ - ├── index.html - └── assets/ -``` - ---- - -## 🔧 四、常见问题 - -### Q1: SSH 连接失败 - -**A**: 检查以下几点: -1. 服务器地址和端口是否正确 -2. 用户名密码是否正确 -3. 服务器防火墙是否开放 SSH 端口 -4. 本地网络是否正常 - -### Q2: MySQL 连接失败 - -**A**: 检查以下几点: -1. MySQL 地址和端口是否正确 -2. 用户名密码是否正确 -3. 数据库是否存在 -4. MySQL 是否允许远程连接 - -### Q3: 构建失败 - -**A**: -1. 检查是否安装了 Node.js 和 npm -2. 检查项目目录是否正确 -3. 运行 `npm install` 安装依赖 -4. 查看错误日志 - -### Q4: 上传速度慢 - -**A**: -1. 检查网络带宽 -2. 考虑使用国内服务器 -3. 可以先在本地构建,然后手动上传 - -### Q5: 如何回滚版本? - -**A**: -1. 在数据库中修改版本号为旧版本 -2. 或删除 `/static/core/{new_version}/` 目录 - ---- - -## 📝 五、配置文件说明 - -### deploy_config.json - -| 字段 | 说明 | 示例 | -|------|------|------| -| `host` | 服务器地址 | `your-server.com` | -| `port` | SSH 端口 | `22` | -| `username` | SSH 用户名 | `root` | -| `password` | SSH 密码 | `your-password` | -| `remote_path` | 远程静态文件路径 | `/var/www/DesignerCEP/Server/static` | -| `mysql.host` | MySQL 地址 | `localhost` | -| `mysql.port` | MySQL 端口 | `3306` | -| `mysql.username` | MySQL 用户名 | `root` | -| `mysql.password` | MySQL 密码 | `your-mysql-password` | -| `mysql.database` | 数据库名 | `designer_cep` | -| `mysql.table` | 表名 | `plugin_groups` | - -### deploy_config.txt (admin_gui.py 使用) - -纯文本格式,一行一个配置: -``` -host=your-server.com -port=22 -user=root -password=your-password -remote_path=/var/www/DesignerCEP/Server/static -project_root=D:\main\DesignerCEP -version=1.0.6 -``` - ---- - -## 🎉 六、快速开始 - -### 方法一:命令行部署(推荐) - -```bash -# 1. 首次配置 -python auto_deploy_core.py --version 1.0.6 --setup - -# 2. 部署到服务器 -python auto_deploy_core.py --version 1.0.6 --deploy --update-db - -# 完成! -``` - -### 方法二:图形化部署 - -```bash -# 1. 启动图形界面 -python admin_gui.py - -# 2. 切换到"自动化部署"标签 -# 3. 配置服务器信息 -# 4. 点击"开始部署"按钮 - -# 完成! -``` - ---- - -## 📞 技术支持 - -如遇到问题,请检查: -1. 部署日志输出 -2. 服务器 SSH 日志 -3. MySQL 连接状态 -4. 网络连接情况 - ---- - -**最后更新**: 2024-12-17 +- 部署前先在目标服务器手工验证 `docker-compose` 可用。 +- 首次部署建议先使用“检查状态”确认容器健康。 +- 生产环境不要将 SSH 密码和 Token 提交进仓库。 diff --git a/PltService/README.md b/PltService/README.md index 449d64c..7df1914 100644 --- a/PltService/README.md +++ b/PltService/README.md @@ -1,112 +1,61 @@ # PLT 裁片处理微服务 -独立的 PLT 文件处理服务,可部署到阿里云 SAE。 +独立 FastAPI 服务,用于解析 PLT 并输出按尺码分组的裁片 PNG(Base64)。 -## 本地运行 +## 1. 启动方式 ```bash -# 安装依赖 +cd PltService pip install -r requirements.txt - -# 启动服务 python main.py ``` -服务启动后访问:http://localhost:8080 +默认监听:`http://localhost:8080` +可通过环境变量 `PORT` 修改端口。 -## Docker 构建 +## 2. Docker 运行 ```bash -# 构建镜像 +cd PltService docker build -t plt-service:latest . - -# 运行容器 docker run -p 8080:8080 plt-service:latest ``` -## 部署到阿里云 SAE +## 3. API -### 1. 构建并推送镜像到阿里云容器镜像服务 +### 3.1 健康检查 -```bash -# 登录阿里云容器镜像服务 -docker login --username=<你的阿里云账号> registry.cn-hangzhou.aliyuncs.com +- `GET /health` -# 构建镜像 -docker build -t registry.cn-hangzhou.aliyuncs.com/<命名空间>/plt-service:v1.0 . +### 3.2 PLT 处理 -# 推送镜像 -docker push registry.cn-hangzhou.aliyuncs.com/<命名空间>/plt-service:v1.0 -``` - -### 2. 在 SAE 创建应用 - -1. 进入阿里云 SAE 控制台 -2. 创建应用 → 选择"镜像部署" -3. 填写镜像地址:`registry.cn-hangzhou.aliyuncs.com/<命名空间>/plt-service:v1.0` -4. 配置规格: - - CPU: 2核 - - 内存: 4GB - - 最小实例数: 0(无请求时不收费) - - 最大实例数: 5 -5. 完成创建 - -### 3. 配置公网访问 - -在 SAE 应用详情 → 基本信息 → SLB 设置 → 添加公网 SLB - -## API 接口 - -### 健康检查 - -``` -GET /health -``` - -### 处理 PLT 文件 - -``` -POST /process -Content-Type: multipart/form-data +- `POST /process` +- `Content-Type: multipart/form-data` 参数: -- file: PLT 文件 -- size_labels: 尺码标签,如 ["S","M","L","XL","2XL"] -- dpi: 输出分辨率(默认 150) -- rotation: 旋转角度(0/90/-90/180) -``` -响应示例: -```json -{ - "success": true, - "total_groups": 5, - "groups": [ - { - "group_id": 1, - "pieces": [ - { - "size": "S", - "image_base64": "data:image/png;base64,...", - "width_px": 500, - "height_px": 300, - "width_cm": 25.5, - "height_cm": 15.3, - "center_x_cm": 12.75, - "center_y_cm": 7.65, - "left_cm": 0, - "top_cm": 0 - } - ] - } - ] -} -``` +- `file`:PLT 文件 +- `size_labels`:JSON 字符串(如 `["S","M","L","XL"]`) +- `dpi`:图片 DPI,默认 `150` +- `rotation`:旋转角度,支持 `0/90/-90/180` -## 费用估算(阿里云 SAE) +返回: -| 场景 | 费用 | -|------|------| -| 处理 1 个 PLT(30秒) | ¥0.04 | -| 每天 100 个 PLT | ¥4/天 | -| 无请求时 | ¥0(最小实例设为0) | +- `success` +- `total_groups` +- `groups[]`(每组包含各尺码裁片的 Base64 图像和坐标信息) + +## 4. 依赖 + +见 `requirements.txt`,核心包括: + +- `fastapi` / `uvicorn` +- `opencv-python-headless` +- `shapely` +- `scipy` +- `Pillow` + +## 5. 部署备注 + +- 可直接镜像化部署到 SAE/K8s/云主机。 +- 生产建议限制上传文件大小,并在网关层增加鉴权与限流。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..178731d --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# DesignerCEP 项目文档(当前仓库版) + +本仓库是一个包含前端、后端、管理工具和 PLT 处理服务的多模块项目。 + +## 1. 模块说明 + +| 模块 | 路径 | 技术栈 | 用途 | +|---|---|---|---| +| Designer | `Designer/` | Vue3 + Vite + TS | 主前端/CEP 面板 | +| AdminPanel | `AdminPanel/` | Vue3 + Vite + TS | 管理面板前端 | +| Server | `Server/` | FastAPI + SQLAlchemy | 主后端 API | +| AdminTool | `AdminTool/` | PyQt5 + Paramiko | 管理/部署 GUI 工具 | +| PltService | `PltService/` | FastAPI + OpenCV + Shapely | 独立 PLT 裁片处理服务 | + +## 2. 推荐环境 + +- Node.js 18+ +- Python 3.12(与 `Server/Dockerfile` 保持一致) +- Docker + Docker Compose(用于后端容器部署) + +## 3. 快速本地启动 + +### 3.1 启动后端(Server) + +```bash +cd Server +pip install -r requirements.txt +uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +``` + +健康检查: + +```bash +curl http://localhost:8000/health +``` + +### 3.2 启动主前端(Designer) + +```bash +cd Designer +npm install +npm run dev +``` + +默认开发地址:`http://localhost:5173` + +### 3.3 启动管理前端(AdminPanel) + +```bash +cd AdminPanel +npm install +npm run dev +``` + +默认开发地址:`http://localhost:5180` + +### 3.4 启动管理工具(AdminTool,可选) + +```bash +cd AdminTool +pip install -r requirements.txt +python admin_gui.py +``` + +仅进入部署页: + +```bash +python deploy_tool.py +``` + +### 3.5 启动 PLT 微服务(可选) + +```bash +cd PltService +pip install -r requirements.txt +python main.py +``` + +默认端口:`8080` + +## 4. 构建说明 + +### Designer + +```bash +cd Designer +npm run build +``` + +产物目录:`Designer/dist_core/` + +### AdminPanel + +```bash +cd AdminPanel +npm run build +``` + +产物目录:`AdminPanel/dist/` + +## 5. 后端 Docker 部署(Server) + +```bash +cd Server +docker-compose up -d --build +docker-compose ps +``` + +## 6. 配置与安全说明 + +- 运行配置请优先参考 `Server/.env.example`,再在本地复制为 `Server/.env`。 +- `Server/.env` 中包含核心配置(数据库、密钥、邮件、AI 服务等)。 +- 当前仓库中的 `.env` 若包含真实密钥,不适合继续共享。建议立即轮换所有密钥,并改为仅保留 `.env.example` 模板。 +- 生产环境请务必修改: + - `SECRET_KEY` + - `ADMIN_TOKEN` + - 数据库账号密码 + - AI/云存储密钥 + +## 7. 数据库迁移 + +- 正式环境推荐使用 Alembic:`cd Server && .venv\Scripts\python -m alembic upgrade head` +- 本地 SQLite 仍保留轻量补列兜底逻辑,但它只适合开发调试 +- 迁移细节见 `Server/migrations/README.md` + +## 8. 备注 + +- 旧文档里提到的 `auto_deploy_core.py`、`npm run build:core` 与当前代码不一致,已以本文件和各子模块 README 为准。 + +## 9. 最近前端更新(2026-03-09) + +- `Designer` 登录页已支持“记住账号 / 记住密码”。 +- `Designer` 个人中心已做风格统一,移除顶部统计卡片,减少视觉干扰。 +- `Designer` AI 助手已开始拆分为头部 / 消息流 / 输入区组件,工具状态改为按会话隔离。 diff --git a/Server/.env.example b/Server/.env.example new file mode 100644 index 0000000..2c6cd44 --- /dev/null +++ b/Server/.env.example @@ -0,0 +1,40 @@ +ENV=development +PROJECT_NAME=DesignerCEP +API_V1_STR=/api/v1 +DATABASE_URL=sqlite:///./designercep.db +SECRET_KEY=replace-with-a-long-random-secret +ACCESS_TOKEN_EXPIRE_MINUTES=10080 +ALLOWED_ORIGINS=* +ADMIN_TOKEN=replace-with-admin-token + +# SMTP +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +EMAILS_FROM_EMAIL= +EMAILS_FROM_NAME=Designer + +# 七牛云对象存储 +QINIU_ACCESS_KEY= +QINIU_SECRET_KEY= +QINIU_BUCKET= +QINIU_DOMAIN=http://image.xinhui.cloud + +# AI(推荐统一使用 AI_*) +AI_PROVIDER=ark +AI_API_KEY= +AI_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 +AI_CHAT_BASE_URL= +AI_VISION_BASE_URL= +AI_IMAGE_BASE_URL= +AI_MODEL=doubao-seed-2-0-mini-260215 +AI_VISION_MODEL=doubao-seed-2-0-mini-260215 +AI_IMAGE_EDIT_MODEL=doubao-seedream-5-0-260128 +AI_CHAT_MODELS=doubao-seed-2-0-mini-260215 +AI_VISION_MODELS=doubao-seed-2-0-mini-260215 +AI_IMAGE_EDIT_MODELS=doubao-seedream-5-0-260128 + +# 兼容旧字段 +ARK_API_KEY= +ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 diff --git a/Server/README.md b/Server/README.md index b93d8dd..91e6e01 100644 --- a/Server/README.md +++ b/Server/README.md @@ -1,109 +1,103 @@ -# 📁 Server 目录说明 +# Server(FastAPI 后端) -## 目录结构 +当前目录是 DesignerCEP 的主后端服务。 -``` +## 1. 当前目录结构(关键) + +```text Server/ -├── app/ ← 后端代码(FastAPI) -│ ├── api/ ← API 路由 -│ ├── core/ ← 核心配置 -│ ├── models/ ← 数据库模型 -│ ├── schemas/ ← 数据验证 -│ ├── services/ ← 业务逻辑 -│ ├── main.py ← 后端入口 -│ └── db.py ← 数据库连接 -│ -├── static/ ← ⭐ 前端静态文件(重要!) -│ └── app/ ← 前端构建产物放这里 -│ ├── index.html ← 前端入口 -│ ├── assets/ ← JS/CSS/图片 -│ └── ... -│ -├── archives/ ← Core 版本压缩包(历史版本) -│ ├── core-v1.0.0.zip -│ ├── core-v1.0.1.zip -│ └── ... -│ -├── tests/ ← 测试代码 -├── routers/ ← 额外的路由(如果有) -│ -├── docker-compose.yml ← Docker 编排配置 -├── Dockerfile ← 后端镜像构建 -├── requirements.txt ← Python 依赖 -├── mysql.cnf ← MySQL 配置 -├── .dockerignore ← Docker 忽略文件 -│ -└── designercep.db ← SQLite 数据库(本地开发用) +├── app/ # FastAPI 代码 +│ ├── api/v1/ # 接口路由 +│ ├── core/ # 配置与安全 +│ ├── models/ # ORM 模型 +│ ├── schemas/ # Pydantic Schema +│ ├── services/ # 业务服务 +│ ├── db.py # SQLAlchemy 初始化与 seed +│ └── main.py # 应用入口 +├── archives/ # 上传版本包归档目录 +├── debug_images/ # 调试图片输出 +├── docker-compose.yml # Docker 编排(backend + mysql + pma + portainer) +├── Dockerfile # 后端镜像 +├── requirements.txt # Python 依赖 +├── start.sh # 容器启动脚本 +├── .env.example # 配置模板(推荐复制后本地填写) +└── .env # 运行配置(敏感) ``` ---- - -## ⭐ 前端文件应该放在这里 - -### 构建前端后,复制到这里: - -``` -Server/static/app/ -├── index.html ← 前端入口(重要!) -├── assets/ ← JS/CSS 等资源 -│ ├── index-xxx.js -│ ├── index-xxx.css -│ └── ... -├── CSInterface.js ← CEP 桥接文件 -└── vite.svg ← 图标等 -``` - -### 操作步骤: - -```bash -# 1. 构建前端(在 Designer 目录) -cd Designer -npm run build:core - -# 2. 复制到 Server/static/app/ -# Windows: -xcopy /E /Y dist_core\* ..\Server\static\app\ - -# Linux/Mac: -cp -r dist_core/* ../Server/static/app/ -``` - ---- - -## 🐳 Docker 部署 - -### 启动服务 +## 2. 本地运行 ```bash cd Server -docker-compose up -d +copy .env.example .env +pip install -r requirements.txt +uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload ``` -### 查看日志 +检查服务: ```bash -docker-compose logs -f +curl http://localhost:8000/health ``` -### 停止服务 +## 3. Docker 运行 + +```bash +cd Server +docker-compose up -d --build +docker-compose ps +docker-compose logs -f backend +``` + +停止服务: ```bash docker-compose down ``` ---- +## 4. 配置说明(来自 `app/core/config.py`) -## 📋 部署清单 +主要环境变量(优先使用 `AI_*`): -- [ ] 前端文件已复制到 `static/app/` -- [ ] `.env` 文件已配置 -- [ ] MySQL 密码已修改 -- [ ] SECRET_KEY 已修改 -- [ ] ALLOWED_ORIGINS 已配置 -- [ ] Docker 服务已启动 -- [ ] 访问 https://app.aidg168.uk/ 测试 +- `ENV`(development / production) +- `DATABASE_URL` +- `SECRET_KEY` +- `ADMIN_TOKEN` +- `ALLOWED_ORIGINS` +- `SMTP_*` +- `QINIU_*` +- `AI_API_KEY`、`AI_BASE_URL`、`AI_MODEL` +- `AI_VISION_MODEL`、`AI_IMAGE_EDIT_MODEL` +- `QINIU_DOMAIN` ---- +## 5. 数据库迁移 -**重要**:前端文件必须放在 `Server/static/app/` 目录! +推荐使用 Alembic: + +```bash +cd Server +.venv\Scripts\python -m alembic upgrade head +``` + +新增字段后生成迁移: + +```bash +.venv\Scripts\python -m alembic revision --autogenerate -m "describe_change" +``` + +## 6. 接口前缀与入口 + +- API 前缀:`/api/v1` +- 健康检查:`GET /health` +- 启动入口:`app/main.py` + +## 7. 关于静态文件 + +当前仓库中没有 `Server/static/app/` 目录。 +在线部署场景下,前端静态资源由 Caddy/Nginx 单独托管(仓库根目录 `Caddyfile` 示例为 `/var/www/app`)。 + +## 8. 安全注意事项 + +- `Server/.env` 含真实密钥,不应继续明文共享。 +- 启动时如果检测到默认密钥,后端会输出配置警告。 +- 部署前请至少轮换:`SECRET_KEY`、`ADMIN_TOKEN`、数据库密码、AI 与云存储密钥。 diff --git a/Server/alembic/env.py b/Server/alembic/env.py index 9d61def..0e34fc4 100644 --- a/Server/alembic/env.py +++ b/Server/alembic/env.py @@ -1,20 +1,21 @@ -import os import sys from logging.config import fileConfig +from pathlib import Path from sqlalchemy import engine_from_config from sqlalchemy import pool from alembic import context -# 将当前目录添加到 sys.path,以便能够导入 app 模块 -sys.path.append(os.getcwd()) +SERVER_DIR = Path(__file__).resolve().parents[1] +if str(SERVER_DIR) not in sys.path: + sys.path.append(str(SERVER_DIR)) # 导入配置和模型 from app.core.config import settings from app.db import Base # 导入所有模型以确保它们被注册到 Base.metadata -from app.models import user, group, business, session +from app.models import business, chat, group, logs, session, user # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/Server/app/api/v1/ai_chat.py b/Server/app/api/v1/ai_chat.py index c67ce46..9e9c09a 100644 --- a/Server/app/api/v1/ai_chat.py +++ b/Server/app/api/v1/ai_chat.py @@ -7,116 +7,231 @@ AI 聊天接口 from fastapi import APIRouter, HTTPException, Depends from pydantic import BaseModel from typing import List, Optional -import json, base64, re, logging +import json, logging from sqlalchemy.orm import Session as DBSession from app.core.config import settings from app.core.security import get_current_user from app.db import get_db from app.models.user import User from app.models.chat import ChatSession, ChatMessage -from app.api.v1.ai_tools import PS_TOOLS, TOOL_DISPLAY_NAMES +from app.api.v1.ai_skills import list_ai_skills_public, resolve_ai_skill from app.api.v1.ai_llm import ( - SYSTEM_PROMPT, VISION_PROMPT, - is_gemini_model, call_llm_with_tools, call_gemini_with_tools, - call_vision_llm, call_gemini, call_image_model, - verify_pattern_result, mock_reply, + call_llm_with_tools, + call_vision_llm, + get_runtime_ai_config, + has_ai_config, + mock_reply, ) log = logging.getLogger(__name__) -# 防止 uvicorn --reload 导致 handler 重复 if not log.handlers: log.setLevel(logging.INFO) log.propagate = False _h = logging.StreamHandler() - _h.setFormatter(logging.Formatter('[%(asctime)s][%(name)s][%(levelname)s] %(message)s')) + _h.setFormatter( + logging.Formatter("[%(asctime)s][%(name)s][%(levelname)s] %(message)s") + ) log.addHandler(_h) router = APIRouter() # ==================== 数据模型 ==================== + class ChatRequest(BaseModel): message: str session_id: Optional[int] = None image_base64: Optional[str] = None - model: Optional[str] = None # 前端指定对话模型(覆盖默认) - vision_model: Optional[str] = None # 前端指定视觉模型 - image_edit_model: Optional[str] = None # 前端指定图片编辑模型 + skill_id: Optional[str] = None + model: Optional[str] = None + vision_model: Optional[str] = None + image_edit_model: Optional[str] = None + class ToolResultRequest(BaseModel): - """前端执行完工具后回传结果""" session_id: int tool_call_id: str tool_name: str result: str - model: Optional[str] = None # 前端指定对话模型 + skill_id: Optional[str] = None + model: Optional[str] = None vision_model: Optional[str] = None image_edit_model: Optional[str] = None -class GeneratePatternRequest(BaseModel): - """从成衣图片生成面料花样""" - image_base64: str # 成衣图片 base64 - canvas_base64: Optional[str] = None # 裁片轮廓截图 base64 - prompt: Optional[str] = None # 可选自定义提示词 - image_edit_model: Optional[str] = None -class VerifyResultRequest(BaseModel): - """验证套图结果""" - garment_base64: str # 原始成衣图片 - canvas_base64: str # 套图后的画布截图 - prompt: Optional[str] = None - vision_model: Optional[str] = None +class WorkflowStageUpdate(BaseModel): + id: str + title: str + description: str + status: str + detail: Optional[str] = None + + +class SessionWorkflowUpdateRequest(BaseModel): + session_id: int + active_skill_id: Optional[str] = None + workflow_state: List[WorkflowStageUpdate] = [] + + +def _should_use_vision_shortcut(skill_mode: str, message: str, has_image: bool) -> bool: + if not has_image: + return False + lowered = (message or "").lower() + help_or_action_tokens = ( + "怎么做", + "怎么操作", + "如何操作", + "教我", + "帮我做", + "帮我改", + "帮我操作", + "直接做", + "不会", + "为什么", + "报错", + "错误", + "失败", + "没反应", + "不能", + "无法", + "步骤", + "下一步", + ) + if any(token in lowered for token in help_or_action_tokens): + return False + if skill_mode == "vision_chat": + return True + if skill_mode != "hybrid_chat": + return False + + action_tokens = ( + "出图", + "生成", + "方案图", + "方向图", + "效果图", + "背景图", + "概念图", + "排版", + "执行", + "落地", + ) + if any(token in lowered for token in action_tokens): + return False + + analysis_tokens = ("看图", "分析", "你看见", "风格", "版型", "配色", "元素", "建议") + return any(token in lowered for token in analysis_tokens) + + +def _decode_workflow_state(raw: Optional[str]) -> list[dict]: + if not raw: + return [] + try: + payload = json.loads(raw) + return payload if isinstance(payload, list) else [] + except Exception: + return [] + + +def _store_session_state( + session: ChatSession, + db: DBSession, + *, + active_skill_id: Optional[str] = None, + workflow_state: Optional[list[dict]] = None, +): + if active_skill_id is not None: + session.active_skill_id = active_skill_id + if workflow_state is not None: + session.workflow_state = json.dumps(workflow_state, ensure_ascii=False) + db.add(session) + # ==================== 对话管理接口 ==================== + @router.get("/ai/models") -async def get_models(current_username: str = Depends(get_current_user)): +async def get_models( + db: DBSession = Depends(get_db), current_username: str = Depends(get_current_user) +): """获取可用模型列表和当前默认值""" + def build_options(configured: str, current: str, default_name: str): + items = [item.strip() for item in configured.split(",") if item.strip()] + if current and current not in items: + items.insert(0, current) + if not items and current: + items = [current] + return [{"id": item, "name": item or default_name} for item in items] + + user = db.query(User).filter(User.username == current_username).first() + chat_model = (getattr(user, "ai_model", "") or "").strip() or settings.AI_MODEL + vision_model = (getattr(user, "ai_vision_model", "") or "").strip() or settings.AI_VISION_MODEL + image_model = (getattr(user, "ai_image_model", "") or "").strip() or settings.AI_IMAGE_EDIT_MODEL + return { "code": 200, "data": { - "chat_model": settings.AI_MODEL, - "vision_model": settings.AI_VISION_MODEL, - "image_edit_model": settings.AI_IMAGE_EDIT_MODEL, - "chat_models": [ - {"id": "qwen3-max-2026-01-23", "name": "Qwen3 Max"}, - {"id": "gemini-2.5-pro-thinking", "name": "Gemini 2.5 Pro"}, - ], - "vision_models": [ - {"id": "qwen-vl-max-latest", "name": "Qwen VL Max"}, - {"id": "gemini-2.5-flash-image", "name": "Gemini 2.5 Flash"}, - ], - "image_edit_models": [ - {"id": "qwen-image-edit-max-2026-01-16", "name": "Qwen 图片编辑"}, - {"id": "gemini-3-pro-image-preview", "name": "Gemini 3 Pro 生图"}, - ], - } + "chat_model": chat_model, + "vision_model": vision_model, + "image_edit_model": image_model, + "chat_models": build_options( + settings.AI_CHAT_MODELS, chat_model, "当前对话模型" + ), + "vision_models": build_options( + settings.AI_VISION_MODELS, + vision_model, + "当前视觉模型", + ), + "image_edit_models": build_options( + settings.AI_IMAGE_EDIT_MODELS, + image_model, + "当前图片模型", + ), + }, + } + + +@router.get("/ai/skills") +async def get_skills(current_username: str = Depends(get_current_user)): + """获取可用技能列表""" + return { + "code": 200, + "data": { + "default_skill": "auto", + "skills": list_ai_skills_public(), + }, } @router.get("/ai/sessions") async def list_sessions( - db: DBSession = Depends(get_db), - current_username: str = Depends(get_current_user) + db: DBSession = Depends(get_db), current_username: str = Depends(get_current_user) ): """获取当前用户的对话列表""" user = db.query(User).filter(User.username == current_username).first() if not user: raise HTTPException(status_code=404, detail="用户不存在") - sessions = db.query(ChatSession)\ - .filter(ChatSession.user_id == user.id)\ - .order_by(ChatSession.updated_at.desc())\ - .limit(50).all() + sessions = ( + db.query(ChatSession) + .filter(ChatSession.user_id == user.id) + .order_by(ChatSession.updated_at.desc()) + .limit(50) + .all() + ) return { "code": 200, - "data": [{ - "id": s.id, - "title": s.title, - "created_at": s.created_at.isoformat() if s.created_at else None, - "updated_at": s.updated_at.isoformat() if s.updated_at else None, - } for s in sessions] + "data": [ + { + "id": s.id, + "title": s.title, + "active_skill_id": s.active_skill_id, + "created_at": s.created_at.isoformat() if s.created_at else None, + "updated_at": s.updated_at.isoformat() if s.updated_at else None, + } + for s in sessions + ], } @@ -124,34 +239,46 @@ async def list_sessions( async def get_session_messages( session_id: int, db: DBSession = Depends(get_db), - current_username: str = Depends(get_current_user) + current_username: str = Depends(get_current_user), ): """获取某个对话的全部消息""" user = db.query(User).filter(User.username == current_username).first() if not user: raise HTTPException(status_code=404, detail="用户不存在") - session = db.query(ChatSession).filter( - ChatSession.id == session_id, ChatSession.user_id == user.id - ).first() + session = ( + db.query(ChatSession) + .filter(ChatSession.id == session_id, ChatSession.user_id == user.id) + .first() + ) if not session: raise HTTPException(status_code=404, detail="对话不存在") - messages = db.query(ChatMessage)\ - .filter(ChatMessage.session_id == session_id)\ - .order_by(ChatMessage.created_at).all() + messages = ( + db.query(ChatMessage) + .filter(ChatMessage.session_id == session_id) + .order_by(ChatMessage.created_at) + .all() + ) return { "code": 200, "data": { "session_id": session.id, "title": session.title, - "messages": [{ - "id": m.id, "role": m.role, "content": m.content, - "tool_calls": m.tool_calls, - "created_at": m.created_at.isoformat() if m.created_at else None, - } for m in messages] - } + "active_skill_id": session.active_skill_id, + "workflow_state": _decode_workflow_state(session.workflow_state), + "messages": [ + { + "id": m.id, + "role": m.role, + "content": m.content, + "tool_calls": m.tool_calls, + "created_at": m.created_at.isoformat() if m.created_at else None, + } + for m in messages + ], + }, } @@ -159,16 +286,18 @@ async def get_session_messages( async def delete_session( session_id: int, db: DBSession = Depends(get_db), - current_username: str = Depends(get_current_user) + current_username: str = Depends(get_current_user), ): """删除对话""" user = db.query(User).filter(User.username == current_username).first() if not user: raise HTTPException(status_code=404, detail="用户不存在") - session = db.query(ChatSession).filter( - ChatSession.id == session_id, ChatSession.user_id == user.id - ).first() + session = ( + db.query(ChatSession) + .filter(ChatSession.id == session_id, ChatSession.user_id == user.id) + .first() + ) if not session: raise HTTPException(status_code=404, detail="对话不存在") @@ -178,27 +307,68 @@ async def delete_session( return {"code": 200, "message": "对话已删除"} +@router.post("/ai/sessions/workflow") +async def update_session_workflow( + data: SessionWorkflowUpdateRequest, + db: DBSession = Depends(get_db), + current_username: str = Depends(get_current_user), +): + user = db.query(User).filter(User.username == current_username).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + session = ( + db.query(ChatSession) + .filter(ChatSession.id == data.session_id, ChatSession.user_id == user.id) + .first() + ) + if not session: + raise HTTPException(status_code=404, detail="对话不存在") + + workflow_payload = [stage.model_dump() for stage in data.workflow_state] + _store_session_state( + session, + db, + active_skill_id=data.active_skill_id, + workflow_state=workflow_payload, + ) + db.commit() + + return { + "code": 200, + "data": { + "session_id": session.id, + "active_skill_id": session.active_skill_id, + "workflow_state": workflow_payload, + }, + } + + # ==================== 聊天接口 ==================== + @router.post("/ai/chat") async def chat( data: ChatRequest, db: DBSession = Depends(get_db), - current_username: str = Depends(get_current_user) + current_username: str = Depends(get_current_user), ): """发送消息 → AI 回复(可能包含工具调用指令)""" - log.info(f"{'='*60}") - log.info(f"[Chat] 收到消息: '{data.message[:80]}' session={data.session_id} 有图片={'是' if data.image_base64 else '否'}") + log.info(f"{'=' * 60}") + log.info( + f"[Chat] 收到消息: '{data.message[:80]}' session={data.session_id} 有图片={'是' if data.image_base64 else '否'}" + ) user = db.query(User).filter(User.username == current_username).first() if not user: raise HTTPException(status_code=404, detail="用户不存在") - # 1. 获取或创建会话 session = None if data.session_id: - session = db.query(ChatSession).filter( - ChatSession.id == data.session_id, ChatSession.user_id == user.id - ).first() + session = ( + db.query(ChatSession) + .filter(ChatSession.id == data.session_id, ChatSession.user_id == user.id) + .first() + ) if not session: title = data.message[:30] + ("..." if len(data.message) > 30 else "") @@ -206,49 +376,92 @@ async def chat( db.add(session) db.flush() - # 2. 保存用户消息(图片消息加标记,不存 base64) has_image = bool(data.image_base64) save_content = ("[图片分析] " + data.message) if has_image else data.message db.add(ChatMessage(session_id=session.id, role="user", content=save_content)) db.commit() - # 3. 加载历史 - history = db.query(ChatMessage)\ - .filter(ChatMessage.session_id == session.id)\ - .order_by(ChatMessage.created_at).all() - # tool 角色消息转为 user(避免 LLM 验证 tool_calls 结构报错) - history_list = [{"role": m.role if m.role != "tool" else "user", "content": m.content} for m in history[-20:]] + history = ( + db.query(ChatMessage) + .filter(ChatMessage.session_id == session.id) + .order_by(ChatMessage.created_at) + .all() + ) + history_list = [ + {"role": m.role if m.role != "tool" else "user", "content": m.content} + for m in history[-20:] + ] + history_context = history_list[:-1] if history_list else [] + selected_skill = resolve_ai_skill( + message=data.message, + history_messages=history_context, + requested_skill_id=data.skill_id, + has_image=has_image, + ) + selected_skill_data = selected_skill.to_public_dict() + _store_session_state(session, db, active_skill_id=selected_skill.id) + runtime_ai_config = get_runtime_ai_config(user) + effective_chat_model = (data.model or user.ai_model or settings.AI_MODEL) if user else (data.model or settings.AI_MODEL) + effective_vision_model = (data.vision_model or user.ai_vision_model or settings.AI_VISION_MODEL) if user else (data.vision_model or settings.AI_VISION_MODEL) - # 4. 调用 LLM(统一走工具模型,图片信息通过文本标记传递) try: - if not settings.AI_API_KEY and not (data.model and is_gemini_model(data.model)): + if not has_ai_config(runtime_ai_config): reply_content = mock_reply(data.message, has_image) tool_calls_data = None else: - call_history = history_list - # 有图片时:在消息中标注,让工具模型知道图片已上传可供工具使用 - if has_image: - call_history = history_list[:-1] + [{ - "role": "user", - "content": f"[用户上传了一张图片,已保存可供工具使用] {data.message or '请处理这张图片'}" - }] - - # 根据模型类型路由 - if data.model and is_gemini_model(data.model): - log.info(f"[Chat] 路由到 Gemini: {data.model}") - reply_content, tool_calls_data = call_gemini_with_tools(call_history, data.model) + if _should_use_vision_shortcut( + selected_skill.mode, data.message, has_image + ): + reply_content = call_vision_llm( + data.message or "请分析这张图片。", + data.image_base64 or "", + history_context, + model_override=effective_vision_model, + skill_id=selected_skill.id, + runtime_config=runtime_ai_config, + ) + tool_calls_data = None else: - reply_content, tool_calls_data = call_llm_with_tools(call_history, model_override=data.model) + call_history = history_list + if has_image: + call_history = history_list[:-1] + [ + { + "role": "user", + "content": ( + f"[用户上传了一张图片,已保存可供工具使用]" + f"[当前技能: {selected_skill.name}] " + f"{data.message or '请处理这张图片'}" + ), + } + ] + + ( + reply_content, + tool_calls_data, + selected_skill_data, + ) = call_llm_with_tools( + call_history, + model_override=effective_chat_model, + skill_id=selected_skill.id, + has_image=has_image, + runtime_config=runtime_ai_config, + ) except Exception as e: reply_content = f"AI 请求出错: {str(e)}" tool_calls_data = None - # 5. 保存 AI 回复 - tc_json = json.dumps(tool_calls_data, ensure_ascii=False) if tool_calls_data else None - db.add(ChatMessage( - session_id=session.id, role="assistant", - content=reply_content or "", tool_calls=tc_json - )) + tc_json = ( + json.dumps(tool_calls_data, ensure_ascii=False) if tool_calls_data else None + ) + db.add( + ChatMessage( + session_id=session.id, + role="assistant", + content=reply_content or "", + tool_calls=tc_json, + ) + ) + _store_session_state(session, db, active_skill_id=selected_skill_data.get("id")) db.commit() return { @@ -256,8 +469,9 @@ async def chat( "data": { "session_id": session.id, "content": reply_content or "", - "tool_calls": tool_calls_data - } + "tool_calls": tool_calls_data, + "skill": selected_skill_data, + }, } @@ -265,55 +479,85 @@ async def chat( async def submit_tool_result( data: ToolResultRequest, db: DBSession = Depends(get_db), - current_username: str = Depends(get_current_user) + current_username: str = Depends(get_current_user), ): """前端执行完工具后回传结果,后端继续让 AI 总结""" - log.info(f"{'='*60}") + log.info(f"{'=' * 60}") log.info(f"[ToolResult] 工具: {data.tool_name}, 结果: {data.result[:150]}") user = db.query(User).filter(User.username == current_username).first() if not user: raise HTTPException(status_code=404, detail="用户不存在") - session = db.query(ChatSession).filter( - ChatSession.id == data.session_id, ChatSession.user_id == user.id - ).first() + session = ( + db.query(ChatSession) + .filter(ChatSession.id == data.session_id, ChatSession.user_id == user.id) + .first() + ) if not session: raise HTTPException(status_code=404, detail="对话不存在") - # 1. 保存工具结果为一条消息 - db.add(ChatMessage( - session_id=session.id, role="tool", - content=f"[工具 {data.tool_name} 执行结果]: {data.result}" - )) + db.add( + ChatMessage( + session_id=session.id, + role="tool", + content=f"[工具 {data.tool_name} 执行结果]: {data.result}", + ) + ) db.commit() - # 2. 重新加载历史,让 AI 根据工具结果继续回答 - history = db.query(ChatMessage)\ - .filter(ChatMessage.session_id == session.id)\ - .order_by(ChatMessage.created_at).all() - history_list = [{"role": m.role if m.role != "tool" else "user", "content": m.content} for m in history[-20:]] + history = ( + db.query(ChatMessage) + .filter(ChatMessage.session_id == session.id) + .order_by(ChatMessage.created_at) + .all() + ) + history_list = [ + {"role": m.role if m.role != "tool" else "user", "content": m.content} + for m in history[-20:] + ] + selected_skill = resolve_ai_skill( + message=f"{data.tool_name} 已执行", + history_messages=history_list, + requested_skill_id=data.skill_id, + has_image=False, + ) + selected_skill_data = selected_skill.to_public_dict() + _store_session_state(session, db, active_skill_id=selected_skill.id) + runtime_ai_config = get_runtime_ai_config(user) + effective_chat_model = (data.model or user.ai_model or settings.AI_MODEL) if user else (data.model or settings.AI_MODEL) - # 3. 再次调用 LLM(这次不带 tools,让 AI 总结结果) try: - if not settings.AI_API_KEY and not (data.model and is_gemini_model(data.model)): + if not has_ai_config(runtime_ai_config): reply_content = f"工具 {data.tool_name} 执行完成。" tool_calls_data = None else: - if data.model and is_gemini_model(data.model): - log.info(f"[ToolResult] 路由到 Gemini: {data.model}") - reply_content, tool_calls_data = call_gemini_with_tools(history_list, data.model) - else: - reply_content, tool_calls_data = call_llm_with_tools(history_list, model_override=data.model) + ( + reply_content, + tool_calls_data, + selected_skill_data, + ) = call_llm_with_tools( + history_list, + model_override=effective_chat_model, + skill_id=selected_skill.id, + has_image=False, + runtime_config=runtime_ai_config, + ) except Exception as e: reply_content = f"AI 总结出错: {str(e)}" tool_calls_data = None - # 4. 保存 AI 总结 - tc_json = json.dumps(tool_calls_data, ensure_ascii=False) if tool_calls_data else None - db.add(ChatMessage( - session_id=session.id, role="assistant", - content=reply_content or "", tool_calls=tc_json - )) + tc_json = ( + json.dumps(tool_calls_data, ensure_ascii=False) if tool_calls_data else None + ) + db.add( + ChatMessage( + session_id=session.id, + role="assistant", + content=reply_content or "", + tool_calls=tc_json, + ) + ) + _store_session_state(session, db, active_skill_id=selected_skill_data.get("id")) db.commit() return { @@ -321,413 +565,7 @@ async def submit_tool_result( "data": { "session_id": session.id, "content": reply_content or "", - "tool_calls": tool_calls_data - } + "tool_calls": tool_calls_data, + "skill": selected_skill_data, + }, } - - - -# ==================== 图案生成 & 验证 ==================== - -@router.post("/ai/generate-preview") -async def generate_preview( - data: GeneratePatternRequest, - current_username: str = Depends(get_current_user) -): - """阶段1:成衣照片 + 裁片截图 → AI 生成带轮廓的花样预览图""" - log.info(f"{'='*60}") - log.info(f"[Preview] 收到预览请求, 成衣图: {len(data.image_base64)//1024}KB, 裁片图: {len(data.canvas_base64)//1024 if data.canvas_base64 else 0}KB") - if data.prompt: - log.info(f"[Preview] 自定义提示词: {data.prompt[:150]}") - if not settings.AI_API_KEY or not settings.AI_IMAGE_EDIT_MODEL: - raise HTTPException(400, "AI_API_KEY 或 AI_IMAGE_EDIT_MODEL 未配置") - - try: - # 构造图片列表:成衣 + 裁片截图(如果有) - images = [data.image_base64] - if data.canvas_base64: - images.append(data.canvas_base64) - - default_prompt = ( - "Image 1 是一件成衣照片,Image 2 是裁片轮廓图。" - "请将 Image 1 成衣上的面料花样提取出来,填充到 Image 2 的每个裁片轮廓内。" - "要求:保持花样颜色、比例、方向一致;保留裁片轮廓线;不要改变裁片排列位置。" - ) if data.canvas_base64 else ( - "请从这件成衣中提取面料花样,生成一张干净的花样平铺图。" - ) - - result_url, desc = call_image_model( - images_b64=images, - prompt=data.prompt or default_prompt, - model_override=data.image_edit_model, - ) - return {"code": 200, "data": {"image_url": result_url, "description": desc}} - except Exception as e: - log.error(f"预览生成失败: {e}", exc_info=True) - err_msg = str(e) - if "data_inspection_failed" in err_msg or "inappropriate" in err_msg: - raise HTTPException(400, "图片内容未通过平台安全审核,请更换图片后重试(避免使用含版权角色/敏感内容的图片)") - raise HTTPException(500, f"预览生成失败: {err_msg}") - - -class CropPieceRequest(BaseModel): - """从预览图中按坐标裁切指定裁片区域""" - preview_base64: str # 预览图 base64(从 URL 下载后传入) - canvas_width: int # PS 画布原始宽度 - canvas_height: int # PS 画布原始高度 - piece_left: float # 裁片在画布中的 left (px) - piece_top: float # 裁片在画布中的 top (px) - piece_width: float # 裁片宽度 (px) - piece_height: float # 裁片高度 (px) - piece_name: str # 裁片名称(用于日志) - padding: float = 0.15 # 出血线比例(向外扩 15%) - - -@router.post("/ai/crop-piece") -async def crop_piece( - data: CropPieceRequest, - current_username: str = Depends(get_current_user) -): - """从预览图中按坐标裁切指定裁片区域(Pillow,像素级精准)""" - log.info(f"{'='*60}") - log.info(f"[CropPiece] 裁切: {data.piece_name}") - log.info(f"[CropPiece] 画布: {data.canvas_width}x{data.canvas_height}, 裁片区域: ({data.piece_left},{data.piece_top}) {data.piece_width}x{data.piece_height}") - - try: - result_b64 = _crop_piece_from_preview(data) - return {"code": 200, "data": {"cropped_base64": result_b64, "piece_name": data.piece_name}} - except Exception as e: - log.error(f"裁切失败: {e}", exc_info=True) - raise HTTPException(500, f"裁切失败: {str(e)}") - - -class RefinePieceRequest(BaseModel): - """AI 细化裁切后的花样图""" - cropped_base64: str # 裁切后的图片 base64 - piece_name: str # 裁片名称 - pattern_type: str = "fill_pattern" # fill_pattern / theme_pattern / mixed_fill / mixed_theme - aspect_ratio: Optional[str] = None # 裁片宽高比,如 "3:4" - piece_width: Optional[int] = None # 裁片像素宽 - piece_height: Optional[int] = None # 裁片像素高 - prompt: Optional[str] = None - image_edit_model: Optional[str] = None - - -@router.post("/ai/refine-piece") -async def refine_piece( - data: RefinePieceRequest, - current_username: str = Depends(get_current_user) -): - """AI 细化裁切图:根据图案类型生成不同提示词""" - log.info(f"{'='*60}") - log.info(f"[RefinePiece] 细化: {data.piece_name}, 类型: {data.pattern_type}, 比例: {data.aspect_ratio}") - log.info(f"[RefinePiece] 裁片尺寸: {data.piece_width}x{data.piece_height}, 图片: {len(data.cropped_base64)//1024}KB") - - if not settings.AI_API_KEY or not settings.AI_IMAGE_EDIT_MODEL: - raise HTTPException(400, "AI_API_KEY 或 AI_IMAGE_EDIT_MODEL 未配置") - - try: - # 尺寸描述 - size_hint = "" - if data.aspect_ratio: - size_hint = f"输出图片宽高比必须为 {data.aspect_ratio}。" - if data.piece_width and data.piece_height: - size_hint += f"输出尺寸至少 {int(data.piece_width * 1.15)}x{int(data.piece_height * 1.15)} 像素。" - - # 根据类型生成提示词 - if data.pattern_type == "fill_pattern": - prompt = data.prompt or ( - f"这是从服装裁片中裁切出来的花型图案区域。" - f"请将它处理为一张干净的矩形花型图:" - f"去掉所有轮廓线、标签文字和边缘空白;" - f"花型图案要完整铺满整个矩形,无空白边距;" - f"向四周自然延展重复纹样,保持花型连续无接缝;" - f"保持花样的颜色和细节清晰。{size_hint}" - ) - elif data.pattern_type == "theme_pattern": - prompt = data.prompt or ( - f"这是从服装裁片中裁切出来的主题图案区域(如卡通人物、Logo、印花)。" - f"请提取其中的主题图案,输出为纯白色背景(#FFFFFF)上的主题图案:" - f"主题图案要完整清晰,保持原始颜色和细节;" - f"背景必须是纯白色(#FFFFFF),不要任何其他底色或纹理;" - f"去掉轮廓线和标签文字;主题图案在画面中居中放置。{size_hint}" - ) - elif data.pattern_type == "mixed_fill": - prompt = data.prompt or ( - f"这是从服装裁片中裁切出来的区域,包含主题图案和花型底纹。" - f"请只提取底部的花型/纹理图案,去掉上面的主题图案(人物/Logo):" - f"用底纹自然填充主题图案原来的位置;" - f"花型要完整铺满整个矩形,保持纹样连续;" - f"去掉轮廓线和标签文字。{size_hint}" - ) - elif data.pattern_type == "mixed_theme": - prompt = data.prompt or ( - f"这是从服装裁片中裁切出来的区域,包含主题图案和花型底纹。" - f"请只提取主题图案(人物/Logo/大面积印花),输出为纯白色背景(#FFFFFF):" - f"主题图案要完整清晰,保持原始颜色和细节;" - f"背景必须是纯白色(#FFFFFF),去掉所有底纹;" - f"去掉轮廓线和标签文字;主题图案在画面中居中放置。{size_hint}" - ) - else: - prompt = data.prompt or f"细化这张图片,去掉轮廓线和标签文字,保持清晰。{size_hint}" - - log.info(f"[RefinePiece] 提示词: {prompt[:200]}") - - result_url, desc = call_image_model( - images_b64=[data.cropped_base64], - prompt=prompt, - model_override=data.image_edit_model, - ) - return {"code": 200, "data": {"refined_url": result_url, "piece_name": data.piece_name, "pattern_type": data.pattern_type}} - except Exception as e: - log.error(f"细化失败: {e}", exc_info=True) - err_msg = str(e) - if "data_inspection_failed" in err_msg: - raise HTTPException(400, "图片内容未通过安全审核") - raise HTTPException(500, f"细化失败: {err_msg}") - - -def _crop_piece_from_preview(data: CropPieceRequest) -> str: - """用 Pillow 从预览图中按 PS 坐标裁切指定区域(带出血线)""" - from PIL import Image - import io - - # 解码预览图 - img_bytes = base64.b64decode(data.preview_base64) - preview_img = Image.open(io.BytesIO(img_bytes)) - pw, ph = preview_img.size - - # 坐标映射:PS 画布坐标 → 预览图像素坐标 - scale_x = pw / data.canvas_width - scale_y = ph / data.canvas_height - - # 出血线(按裁片尺寸的百分比向外扩) - bleed_x = data.piece_width * data.padding - bleed_y = data.piece_height * data.padding - - # PS 坐标(含出血)→ 预览图像素 - left = max(0, (data.piece_left - bleed_x) * scale_x) - top = max(0, (data.piece_top - bleed_y) * scale_y) - right = min(pw, (data.piece_left + data.piece_width + bleed_x) * scale_x) - bottom = min(ph, (data.piece_top + data.piece_height + bleed_y) * scale_y) - - log.info(f"[CropPiece] PS 画布: {data.canvas_width}x{data.canvas_height}") - log.info(f"[CropPiece] 预览图: {pw}x{ph}") - log.info(f"[CropPiece] 缩放比: x={scale_x:.4f}, y={scale_y:.4f}") - log.info(f"[CropPiece] PS 裁片区域: ({data.piece_left},{data.piece_top}) {data.piece_width}x{data.piece_height}") - log.info(f"[CropPiece] 出血线: {data.padding*100:.0f}% → x±{bleed_x:.0f}px, y±{bleed_y:.0f}px") - log.info(f"[CropPiece] 预览图裁切: ({left:.0f},{top:.0f})-({right:.0f},{bottom:.0f}) = {right-left:.0f}x{bottom-top:.0f}px") - - cropped = preview_img.crop((int(left), int(top), int(right), int(bottom))) - - # 调试:保存裁切结果 - import os, time - debug_dir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'debug_images') - os.makedirs(debug_dir, exist_ok=True) - ts = int(time.time()) - debug_path = os.path.join(debug_dir, f'{ts}_crop_{data.piece_name}.png') - cropped.save(debug_path) - log.info(f"[CropPiece] 裁切结果已保存: {debug_path}") - - # 转 base64 - buf = io.BytesIO() - cropped.save(buf, format='PNG') - return base64.b64encode(buf.getvalue()).decode() - - -class ExtractPieceRequest(BaseModel): - """从预览图中提取单个裁片花样(旧版 AI 提取,保留兼容)""" - preview_base64: str - piece_name: str - piece_description: str = "" - prompt: Optional[str] = None - - -@router.post("/ai/extract-piece-pattern") -async def extract_piece_pattern( - data: ExtractPieceRequest, - current_username: str = Depends(get_current_user) -): - """阶段2:从预览图中提取指定裁片区域 → 输出矩形花样图""" - log.info(f"{'='*60}") - log.info(f"[ExtractPiece] 裁片: {data.piece_name}, 描述: '{data.piece_description}', 预览图: {len(data.preview_base64)//1024}KB") - if not settings.AI_API_KEY or not settings.AI_IMAGE_EDIT_MODEL: - raise HTTPException(400, "AI_API_KEY 或 AI_IMAGE_EDIT_MODEL 未配置") - - try: - prompt = data.prompt or ( - f"这是一张服装裁片花样预览图,包含多个裁片。" - f"请提取其中【{data.piece_name}】的花样区域" - f"{'(' + data.piece_description + ')' if data.piece_description else ''}," - f"将该区域的花样输出为一张完整的矩形图片。" - f"要求:只保留花样内容,去掉轮廓线,填满整个矩形,保持清晰。" - ) - result_url, desc = call_image_model( - images_b64=[data.preview_base64], - prompt=prompt, - model_override=getattr(data, 'image_edit_model', None), - ) - return {"code": 200, "data": {"pattern_url": result_url, "piece_name": data.piece_name, "description": desc}} - except Exception as e: - log.error(f"裁片提取失败: {e}", exc_info=True) - raise HTTPException(500, f"裁片提取失败: {str(e)}") - - -class IdentifyPiecesRequest(BaseModel): - """识别裁片部位 + 分析颜色花样""" - canvas_base64: str # 画布截图(裁片轮廓) - garment_base64: Optional[str] = None # 成衣照片(用于分析颜色花样) - layers_info: str # 图层信息 - vision_model: Optional[str] = None - - -@router.post("/ai/identify-pieces") -async def identify_pieces( - data: IdentifyPiecesRequest, - current_username: str = Depends(get_current_user) -): - """用视觉模型识别裁片部位 + 分析成衣各部位的颜色花样""" - if not settings.AI_API_KEY: - raise HTTPException(400, "AI_API_KEY 未配置") - - try: - result = _identify_garment_pieces(data.canvas_base64, data.layers_info, data.garment_base64, vision_model=data.vision_model) - return {"code": 200, "data": result} - except Exception as e: - log.error(f"裁片识别失败: {e}", exc_info=True) - raise HTTPException(500, f"裁片识别失败: {str(e)}") - - -def _identify_garment_pieces(canvas_b64: str, layers_info: str, garment_b64: str = None, vision_model: str = None) -> dict: - """用视觉模型分析画布+成衣,识别裁片部位并判断颜色/花样(自动路由 Qwen/Gemini)""" - - use_model = vision_model or settings.AI_VISION_MODEL - - log.info(f"{'='*60}") - log.info(f"[IdentifyPieces] 调用视觉模型识别裁片 + 分析花样") - log.info(f"[IdentifyPieces] 模型: {use_model}") - log.info(f"[IdentifyPieces] 画布: {len(canvas_b64)//1024}KB, 成衣: {len(garment_b64)//1024 if garment_b64 else 0}KB") - log.info(f"[IdentifyPieces] 图层:\n{layers_info[:500]}") - - # 构造提示词 - if garment_b64: - prompt = f"""你需要完成两个任务: - -**任务1:识别裁片部位** -Image 2 是 PS 文档中的裁片轮廓图。以下是图层信息: -{layers_info} - -请根据形状、大小识别每个图层是什么服装部位(前片、后片、袖子、领口等)。 - -**任务2:分析成衣颜色花样** -Image 1 是成衣照片。请分析这件衣服各个部位的视觉特征: -- 哪些部位有印花/花样/图案?描述花样内容 -- 哪些部位是纯色/素色?给出颜色的 hex 值 -- 衣服整体的底色是什么? - -用 JSON 回答,格式: -```json -{{ - "mapping": {{ - "M-1": "前片", - "M-2": "后片" - }}, - "analysis": [ - {{"layer": "M-1", "piece": "前片", "type": "theme_pattern", "color": "#F5E6D0", "description": "卡通女孩图案,米白色纯色底"}}, - {{"layer": "M-2", "piece": "后片", "type": "solid", "color": "#F5E6D0", "description": "米白色纯色"}}, - {{"layer": "M-3", "piece": "左袖", "type": "fill_pattern", "description": "蓝白条纹重复图案"}} - ], - "base_color": "#F5E6D0" -}} -``` - -type 只有四种: -- solid — 纯色,必须给 color(hex值) -- fill_pattern — 重复性花型(碎花/条纹/格子/波点),整片铺满相同纹样 -- theme_pattern — 主题图案(卡通/Logo/大印花)在纯色底上,必须给 color(底色 hex值) -- mixed_pattern — 主题图案叠在花型底纹上(最复杂),需描述主题和底纹 -只返回 JSON,不要其他文字。""" - else: - prompt = f"""这是一张 PS 文档裁片截图。图层信息: -{layers_info} - -识别每个图层的服装部位,用 JSON 回答: -```json -{{"mapping": {{"M-1": "前片", "M-2": "后片"}}}} -``` -只返回 JSON。""" - - # ---------- Gemini 路由 ---------- - if is_gemini_model(use_model): - log.info(f"[IdentifyPieces] 使用 Gemini 视觉: {use_model}") - images = [] - if garment_b64: - images.append(garment_b64) - images.append(canvas_b64) - - content = call_gemini( - [{"role": "user", "content": prompt}], - use_model, - images_b64=images - ) - log.info(f"[IdentifyPieces] Gemini 回复: {content[:500]}") - else: - # ---------- Qwen / OpenAI 路由 ---------- - from openai import OpenAI - - client = OpenAI( - api_key=settings.AI_API_KEY, - base_url=settings.AI_BASE_URL or "https://api.openai.com/v1", - ) - - content_parts = [] - if garment_b64: - content_parts.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{garment_b64}"}}) - content_parts.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{canvas_b64}"}}) - content_parts.append({"type": "text", "text": prompt}) - - completion = client.chat.completions.create( - model=use_model, - messages=[{"role": "user", "content": content_parts}], - ) - - content = completion.choices[0].message.content or "" - log.info(f"[IdentifyPieces] 视觉模型回复: {content[:500]}") - - # 提取 JSON - try: - clean = content.strip() - if "```" in clean: - clean = clean.split("```")[1] - if clean.startswith("json"): - clean = clean[4:] - clean = clean.strip() - parsed = json.loads(clean) - - # 兼容旧格式(纯 mapping)和新格式(mapping + analysis) - if "mapping" in parsed: - return parsed - else: - return {"mapping": parsed} - except json.JSONDecodeError: - log.warning(f"[IdentifyPieces] JSON 解析失败") - return {"mapping": {}, "raw_response": content} - - -@router.post("/ai/verify-result") -async def verify_result( - data: VerifyResultRequest, - current_username: str = Depends(get_current_user) -): - """对比原始成衣和套图结果,给出验证反馈""" - if not settings.AI_API_KEY: - raise HTTPException(400, "AI_API_KEY 未配置") - - try: - feedback = verify_pattern_result(data.garment_base64, data.canvas_base64, data.prompt, vision_model=data.vision_model) - return {"code": 200, "data": {"feedback": feedback}} - except Exception as e: - log.error(f"验证失败: {e}", exc_info=True) - raise HTTPException(500, f"验证失败: {str(e)}") - - diff --git a/Server/app/api/v1/ai_identify.py b/Server/app/api/v1/ai_identify.py new file mode 100644 index 0000000..41ce9b6 --- /dev/null +++ b/Server/app/api/v1/ai_identify.py @@ -0,0 +1,245 @@ +# -*- coding: utf-8 -*- +""" +AI 裁片识别接口 +功能:识别裁片部位、分析颜色花样、验证套图结果 +""" + +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel +from typing import Optional +import json, logging +from app.core.config import settings +from app.core.security import get_current_user +from app.db import get_db +from sqlalchemy.orm import Session +from app.models.user import User +from app.api.v1.ai_llm import ( + call_vision_messages, + get_runtime_ai_config, + verify_pattern_result, + has_ai_config, +) + +log = logging.getLogger(__name__) +if not log.handlers: + log.setLevel(logging.INFO) + log.propagate = False + _h = logging.StreamHandler() + _h.setFormatter( + logging.Formatter("[%(asctime)s][%(name)s][%(levelname)s] %(message)s") + ) + log.addHandler(_h) + +router = APIRouter() + +# ==================== 数据模型 ==================== + + +class IdentifyPiecesRequest(BaseModel): + canvas_base64: str + garment_base64: Optional[str] = None + layers_info: str + vision_model: Optional[str] = None + + +class VerifyResultRequest(BaseModel): + garment_base64: str + canvas_base64: str + prompt: Optional[str] = None + vision_model: Optional[str] = None + + +# ==================== 裁片识别接口 ==================== + + +@router.post("/ai/identify-pieces") +async def identify_pieces( + data: IdentifyPiecesRequest, + current_username: str = Depends(get_current_user), + db: Session = Depends(get_db), +): + """用视觉模型识别裁片部位 + 分析成衣各部位的颜色花样""" + user = db.query(User).filter(User.username == current_username).first() + runtime_ai_config = get_runtime_ai_config(user) + effective_vision_model = ( + data.vision_model + or (user.ai_vision_model if user else None) + or settings.AI_VISION_MODEL + ) + if not has_ai_config(runtime_ai_config): + raise HTTPException(400, "请先在 API 配置页填写转发 Key,或联系管理员配置默认 AI_API_KEY") + + try: + result = _identify_garment_pieces( + data.canvas_base64, + data.layers_info, + data.garment_base64, + vision_model=effective_vision_model, + runtime_ai_config=runtime_ai_config, + ) + return {"code": 200, "data": result} + except Exception as e: + log.error(f"裁片识别失败: {e}", exc_info=True) + raise HTTPException(500, f"裁片识别失败: {str(e)}") + + +@router.post("/ai/verify-result") +async def verify_result( + data: VerifyResultRequest, + current_username: str = Depends(get_current_user), + db: Session = Depends(get_db), +): + """对比原始成衣和套图结果,给出验证反馈""" + user = db.query(User).filter(User.username == current_username).first() + runtime_ai_config = get_runtime_ai_config(user) + effective_vision_model = ( + data.vision_model + or (user.ai_vision_model if user else None) + or settings.AI_VISION_MODEL + ) + if not has_ai_config(runtime_ai_config): + raise HTTPException(400, "请先在 API 配置页填写转发 Key,或联系管理员配置默认 AI_API_KEY") + + try: + feedback = verify_pattern_result( + data.garment_base64, + data.canvas_base64, + data.prompt, + vision_model=effective_vision_model, + runtime_config=runtime_ai_config, + ) + return {"code": 200, "data": {"feedback": feedback}} + except Exception as e: + log.error(f"验证失败: {e}", exc_info=True) + raise HTTPException(500, f"验证失败: {str(e)}") + + +# ==================== 核心识别逻辑 ==================== + + +def _identify_garment_pieces( + canvas_b64: str, + layers_info: str, + garment_b64: str = None, + vision_model: str = None, + runtime_ai_config=None, +) -> dict: + """用视觉模型分析画布+成衣,识别裁片部位并判断颜色/花样""" + use_model = vision_model or settings.AI_VISION_MODEL + + log.info(f"{'=' * 60}") + log.info(f"[IdentifyPieces] 调用视觉模型识别裁片 + 分析花样") + log.info(f"[IdentifyPieces] 模型: {use_model}") + log.info( + f"[IdentifyPieces] 画布: {len(canvas_b64) // 1024}KB, 成衣: {len(garment_b64) // 1024 if garment_b64 else 0}KB" + ) + log.info(f"[IdentifyPieces] 图层:\n{layers_info[:500]}") + + layer_names = [] + for line in layers_info.strip().split("\n"): + name = line.split("(")[0].strip() + if name: + layer_names.append(name) + + ex1 = layer_names[0] if len(layer_names) > 0 else "图层名1" + ex2 = layer_names[1] if len(layer_names) > 1 else "图层名2" + ex3 = layer_names[2] if len(layer_names) > 2 else "图层名3" + + if garment_b64: + prompt = f"""你需要完成三个任务: + +**任务1:识别裁片部位** +Image 2 是 PS 文档中的裁片轮廓图。以下是图层信息: +{layers_info} + +请根据形状、大小识别每个**图层组**是什么服装部位(前片、后片、袖子、领口等)。 +⚠️ mapping 的 key 必须是上面图层信息中的**实际图层组名称**(如 "{ex1}"、"{ex2}" 等),不要自己编名字! + +**任务2:判断花型覆盖模式 pattern_mode** +Image 1 是成衣照片。请判断花型属于哪种模式: +- "all_over":所有裁片来自同一块面料(碎花/格子/渐变等整体花型覆盖全身) +- "placement":各裁片独立处理(有的印花有的纯色,如卡通卫衣) +- "color_block":全部纯色拼接 + +如果是 all_over,还需提供 fabric_info: +- fabric_type: 面料印花类型 +- pattern_direction: 花型方向 +- pattern_elements: 花型元素描述 +- color_transition: 色彩过渡描述 +- density: 花型密度描述 + +**任务3:分析每个裁片** +- type: solid / fill_pattern / theme_pattern / mixed_pattern / all_over +- solid 和 theme_pattern 必须给 color(hex值) +- 如果某个裁片在照片中**看不到**(如后片),设置 visible: false 并推理 +- 左右对称片标记 symmetric_pairs + +用 JSON 回答(key 用实际图层名): +```json +{{ + "pattern_mode": "all_over", + "fabric_info": {{ + "fabric_type": "数码印花", + "pattern_direction": "上下渐变", + "pattern_elements": "白色百合花朵 + 灰白格纹底", + "color_transition": "从黑色渐变到灰白色", + "density": "上部花朵密集大朵,下部稀疏小朵" + }}, + "mapping": {{ + "{ex1}": "左袖", + "{ex2}": "前片" + }}, + "analysis": [ + {{"layer": "{ex1}", "piece": "左袖", "type": "all_over", "visible": true, "description": "黑底白花渐变"}}, + {{"layer": "{ex2}", "piece": "前片", "type": "all_over", "visible": true, "description": "上半黑底大花+下半灰白格纹"}}, + {{"layer": "{ex3}", "piece": "后片", "type": "all_over", "visible": false, "inferred_from": "{ex2}", "inference_reason": "全身花型面料,后片花型与前片相同", "confidence": "high"}} + ], + "symmetric_pairs": [["{ex1}", "对称片名"]], + "base_color": "#000000" +}} +``` + +⚠️ 重要: +- mapping 和 analysis 中的 layer 必须用**实际图层组名称** +- 看不到的部位设 visible: false 并说明推理依据 +- 左右对称的片标记 symmetric_pairs +只返回 JSON,不要其他文字。""" + else: + prompt = f"""这是一张 PS 文档裁片截图。图层信息: +{layers_info} + +识别每个图层组的服装部位,mapping 的 key 必须用**实际图层名**: +```json +{{"mapping": {{"{ex1}": "前片", "{ex2}": "后片"}}}} +``` +只返回 JSON。""" + + log.info(f"[IdentifyPieces] 使用豆包视觉: {use_model}") + images = [] + if garment_b64: + images.append(garment_b64) + images.append(canvas_b64) + content = call_vision_messages( + prompt, + images, + model_override=use_model, + runtime_config=runtime_ai_config, + ) + log.info(f"[IdentifyPieces] 视觉模型回复: {content[:500]}") + + try: + clean = content.strip() + if "```" in clean: + clean = clean.split("```")[1] + if clean.startswith("json"): + clean = clean[4:] + clean = clean.strip() + parsed = json.loads(clean) + + if "mapping" in parsed: + return parsed + else: + return {"mapping": parsed} + except json.JSONDecodeError: + log.warning(f"[IdentifyPieces] JSON 解析失败") + return {"mapping": {}, "raw_response": content} diff --git a/Server/app/api/v1/ai_llm.py b/Server/app/api/v1/ai_llm.py index e0a7cf7..af8cf39 100644 --- a/Server/app/api/v1/ai_llm.py +++ b/Server/app/api/v1/ai_llm.py @@ -1,17 +1,32 @@ # -*- coding: utf-8 -*- """ AI 模型调用层 -统一管理所有 LLM / Vision / Image 模型的调用逻辑 -支持 Qwen (DashScope) 和 Gemini (第三方代理) 两套路由 +统一管理所有 AI 模型的调用逻辑 +当前默认 provider 为 Ark,配置项保持通用命名,并支持技能化执行策略 """ -from typing import List -import json, base64, re, logging +from typing import List, Optional +from dataclasses import dataclass +import json +import logging +import os from app.core.config import settings from app.api.v1.ai_tools import PS_TOOLS, TOOL_DISPLAY_NAMES +from app.api.v1.ai_skills import AiSkill, get_ai_skill, resolve_ai_skill log = logging.getLogger(__name__) + +@dataclass +class RuntimeAiConfig: + provider: str + api_key: str + base_url: str + chat_base_url: str = "" + vision_base_url: str = "" + image_base_url: str = "" + source: str = "server" + # ==================== Prompts ==================== SYSTEM_PROMPT = """你是 DesignerCEP 的 AI 助手,运行在 Adobe Photoshop CEP 插件中。 @@ -20,395 +35,634 @@ SYSTEM_PROMPT = """你是 DesignerCEP 的 AI 助手,运行在 Adobe Photoshop 1. 回答关于 Photoshop 操作和插件使用的问题 2. 通过工具直接操作 Photoshop(创建图层、对齐、查看文档信息等) 3. 帮用户排查操作中遇到的错误 -4. **AI 智能套图**:两阶段流程 — 先生成预览确认,再提取套到裁片上 +4. AI 智能套图:两阶段流程,先生成预览确认,再提取套到裁片上 -## AI 智能套图流程(两阶段) +## AI 智能套图流程 当用户上传成衣图片并要求套图时: - -### 阶段 1 — 识别裁片 + 生成预览(需用户确认) -1. 调用 **identify_pieces** — 截取画布并识别每个图层是什么裁片部位(前片、后片、袖子等) -2. 告诉用户识别结果(如 "M-1=前片, M-2=后片, M-5=左袖...") -3. 调用 **generate_garment_preview** — 会自动在裁片下方标注名称标签(如 "M-1 前片"),然后截取带标签的画布 + 成衣照片一起发给 AI 生成预览 -4. 预览图显示在聊天中,每个裁片都有标签,**等用户确认 OK 后**进入阶段 2 - -### 阶段 2 — 提取花样 + 正式套图(用户确认后) -5. 根据 identify_pieces 分析结果中每个裁片的 type 决定处理方式: - - solid → 设 color 字段,PS 直接纯色填充 - - fill_pattern → AI 提取花型铺满 - - theme_pattern → 底层 PS 纯色填充(设 color)+ 上层 AI 提取主题图案(白底+正片叠底) - - mixed_pattern → 底层 AI 提取花型 + 上层 AI 提取主题图案(白底+正片叠底) -6. 调用 **extract_and_apply_all_pieces** 执行套图 -7. 可选:调用 **verify_pattern_result** 验证效果 +1. 先调用 identify_pieces 识别每个图层是什么裁片部位 +2. 告诉用户识别结果 +3. 调用 generate_garment_preview 生成带标签的裁片预览图 +4. 等用户确认 OK 后,再调用 extract_and_apply_all_pieces 正式套图 重要: -- 阶段 1 完成后必须**等用户说"可以"/"OK"**才执行阶段 2 -- 用户可能会说"袖子纯色"、"后幅不要花样"等,要根据 identify_pieces 的结果对应到正确的图层名 - -重要规则: -- 当用户要求执行 PS 操作时,使用工具完成 -- 执行操作前可以先了解当前文档和图层状态 -- 用简洁的中文回答,适合在小面板中阅读 -- 如果工具执行失败,向用户解释原因并建议解决方案 +- 预览生成后必须等用户确认,不能自己直接进入正式套图 +- 每个工具最多调用 1 次,除非上次执行失败 +- 用户要求执行 Photoshop 操作时,优先使用工具完成 +- 用户如果是在问“不会怎么做 / 为什么不行 / 下一步怎么操作”,要优先结合当前 Photoshop 上下文答疑,不要只给脱离现场的教程 +- 答疑、查询当前信息、执行操作可以在同一轮里完成,不要把它们人为拆成两种模式 +- 如果用户的问题和当前文档、图层、选区、参考图有关,优先先查 1 个最相关的上下文工具,再给结论 +- 如果用户意图已经明确且风险低,可以边解释边执行;如果涉及删除、覆盖、关闭、合并等风险操作,先确认 +- 不要只讲原理不动手,也不要只闷头执行不解释;默认输出“判断 + 当前检查/执行结果 + 下一步” +- 用简洁中文回答,适合在小面板中阅读 +- 复杂任务先给 2-4 条简短思路,再开始执行 +- 默认按 观察 -> 判断 -> 执行 -> 验证 的顺序做事 +- 如果任务包含多步骤,不要一上来同时调很多工具,先完成当前阶段再进入下一阶段 +- 回答里尽量显式说明“当前阶段”和“下一步” """ VISION_PROMPT = """你是一位资深的服装设计分析师,同时也是 DesignerCEP 的 AI 助手。 -当用户发送服装/成衣图片时,请从以下维度进行专业分析: - -1. **服装类别** — 上衣/裤子/裙子/连衣裙/外套/配饰等,细分款式 -2. **面料分析** — 根据视觉特征推测面料类型(棉、涤纶、丝绸、针织、牛仔、雪纺等),分析面料质感 -3. **颜色与印花** — 主色调、配色方案、印花/图案类型及工艺(数码印花、丝网印刷、提花等) -4. **版型特点** — 修身/宽松/A字/H型等,分析领口、袖型、肩线、腰线、下摆处理 -5. **工艺细节** — 缝线工艺、拉链/纽扣/暗扣、口袋设计、装饰细节、包边/锁边 -6. **设计评价** — 设计亮点、风格定位(休闲/正装/运动/时尚等)、目标消费群体 -7. **改进建议** — 如有可改进之处,给出专业建议 +请结合图片和用户问题,从服装类别、面料、颜色印花、版型特点、工艺细节、设计评价、改进建议等维度做专业分析。 规则: -- 如果图片不是服装相关,也请尽力分析图片内容并给出有价值的反馈 -- 如果用户同时提了文字问题,请结合图片和问题一起回答 -- 用清晰、结构化的中文回答,适合在设计工作中参考 -- 回答要专业但不啰嗦,突出重点信息 +- 如果图片不是服装相关,也尽力分析图片内容 +- 用清晰、结构化的中文回答 +- 回答要专业但不啰嗦,突出重点 +""" + +TOOL_BY_NAME = { + tool["function"]["name"]: tool + for tool in PS_TOOLS + if tool.get("type") == "function" and tool.get("function", {}).get("name") +} + + +# ==================== 客户端工厂 ==================== + + +def get_runtime_ai_config(user=None) -> RuntimeAiConfig: + user_provider = (getattr(user, "ai_provider", "") or "").strip().lower() + user_api_key = (getattr(user, "ai_api_key", "") or "").strip() + user_base_url = (getattr(user, "ai_base_url", "") or "").strip() + user_chat_base_url = (getattr(user, "ai_chat_base_url", "") or "").strip() + user_vision_base_url = (getattr(user, "ai_vision_base_url", "") or "").strip() + user_image_base_url = (getattr(user, "ai_image_base_url", "") or "").strip() + + default_base_url = ( + settings.AI_BASE_URL + or settings.ARK_BASE_URL + or "https://ark.cn-beijing.volces.com/api/v3" + ) + + if user_api_key: + return RuntimeAiConfig( + provider=user_provider or (settings.AI_PROVIDER or "ark").strip().lower(), + api_key=user_api_key, + base_url=user_base_url or default_base_url, + chat_base_url=user_chat_base_url or settings.AI_CHAT_BASE_URL or "", + vision_base_url=user_vision_base_url or settings.AI_VISION_BASE_URL or "", + image_base_url=user_image_base_url or settings.AI_IMAGE_BASE_URL or "", + source="user", + ) + + return RuntimeAiConfig( + provider=(settings.AI_PROVIDER or "ark").strip().lower(), + api_key=( + settings.AI_API_KEY + or settings.ARK_API_KEY + or os.getenv("AI_API_KEY", "") + or os.getenv("ARK_API_KEY", "") + ), + base_url=default_base_url, + chat_base_url=settings.AI_CHAT_BASE_URL or "", + vision_base_url=settings.AI_VISION_BASE_URL or "", + image_base_url=settings.AI_IMAGE_BASE_URL or "", + source="server", + ) + +def get_ai_provider(runtime_config: Optional[RuntimeAiConfig] = None) -> str: + return (runtime_config.provider if runtime_config else settings.AI_PROVIDER or "ark").strip().lower() + + +def get_ai_api_key(runtime_config: Optional[RuntimeAiConfig] = None) -> str: + return (runtime_config.api_key if runtime_config else get_runtime_ai_config().api_key).strip() + + +def get_ai_base_url( + runtime_config: Optional[RuntimeAiConfig] = None, capability: str = "chat" +) -> str: + config = runtime_config or get_runtime_ai_config() + if capability == "vision": + return (config.vision_base_url or config.base_url).strip() + if capability == "image": + return (config.image_base_url or config.base_url).strip() + return (config.chat_base_url or config.base_url).strip() + + +def has_ai_config(runtime_config: Optional[RuntimeAiConfig] = None) -> bool: + return bool(get_ai_api_key(runtime_config)) + + +def get_ai_client( + runtime_config: Optional[RuntimeAiConfig] = None, capability: str = "chat" +): + """获取当前 provider 的客户端。""" + from volcenginesdkarkruntime import Ark + + provider = get_ai_provider(runtime_config) + if provider != "ark": + raise ValueError(f"当前暂不支持的 AI_PROVIDER: {provider}") + + api_key = get_ai_api_key(runtime_config) + if not api_key: + raise ValueError("AI_API_KEY 未配置") + + return Ark( + base_url=get_ai_base_url(runtime_config, capability=capability), + api_key=api_key, + ) + + +def is_gemini_model(model_name: str) -> bool: + """兼容旧代码,当前已统一走豆包。""" + return False + + +def _tool_schemas_for_skill(skill: AiSkill) -> List[dict]: + if not skill.allowed_tools: + return [] + return [TOOL_BY_NAME[name] for name in skill.allowed_tools if name in TOOL_BY_NAME] + + +def _numbered_block(items: tuple[str, ...]) -> str: + if not items: + return "无" + return "\n".join(f"{idx + 1}. {item}" for idx, item in enumerate(items)) + + +def _bullet_block(items: tuple[str, ...]) -> str: + if not items: + return "无" + return "\n".join(f"- {item}" for item in items) + + +def _build_skill_prompt(skill: AiSkill, has_image: bool = False) -> str: + workflow = _numbered_block(skill.workflow) + guardrails = _bullet_block(skill.guardrails) + success_criteria = _bullet_block(skill.success_criteria) + triage_questions = _numbered_block(skill.triage_questions) + deliverables = _bullet_block(skill.deliverables) + execution_notes = _bullet_block(skill.execution_notes) + tool_names = ( + "、".join(TOOL_DISPLAY_NAMES.get(name, name) for name in skill.allowed_tools) + if skill.allowed_tools + else "当前技能以分析和建议为主,不主动调用 Photoshop 工具。" + ) + origin = skill.origin or "项目内置技能" + origin_url = skill.origin_url or "无" + upstream_skill = skill.upstream_skill or "无" + + return f"""当前启用技能:{skill.name}({skill.id}) +技能定位:{skill.description} +执行模式:{skill.mode} +规划风格:{skill.planning_style} +图片提示:{skill.image_hint or '无'} +来源:{origin} +上游技能:{upstream_skill} +来源地址:{origin_url} + +推荐工作流: +{workflow} + +预判问题: +{triage_questions} + +期望交付: +{deliverables} + +执行边界: +{guardrails} + +执行备注: +{execution_notes} + +成功标准: +{success_criteria} + +本技能优先工具: +{tool_names} + +本轮补充要求: +- 回复保持中文、简洁、能落地。 +- 涉及具体执行时,先根据当前技能给出简短思路,再调用工具。 +- 根据用户问题动态混合答疑、查询与执行;如果用户是在求助“怎么做/为什么失败/下一步怎么弄”,不要把答疑和执行拆成两轮。 +- 能读取当前 Photoshop 上下文时,优先读当前信息后再回答;不要只给泛化教程。 +- 优先先想清楚计划,再动手执行;如果任务复杂,先向用户同步 2 到 4 条阶段计划。 +- 每轮优先调用最相关的 1 个工具,避免一口气乱调很多工具。 +- 如果图片信息不足、文档上下文不足或风险较高,要先说明再操作。 +- 每完成一个阶段,要用一句中文总结已完成内容,并指向下一步。 +- {'本轮带有图片,请结合图片内容和技能策略做判断。' if has_image else '本轮没有新图片,优先结合聊天历史和 Photoshop 上下文。'} """ -# ==================== 路由判断 ==================== - -def is_gemini_model(model_name: str) -> bool: - """判断是否是 Gemini 模型""" - return bool(model_name) and "gemini" in model_name.lower() +def _chat_system_prompt(skill: AiSkill, has_image: bool = False) -> str: + return f"{SYSTEM_PROMPT}\n\n{_build_skill_prompt(skill, has_image)}" -# ==================== Qwen / OpenAI 兼容调用 ==================== +def _vision_instructions(skill: AiSkill) -> str: + return f"{VISION_PROMPT}\n\n{_build_skill_prompt(skill, has_image=True)}" -def call_llm_with_tools(messages_history: List[dict], model_override: str = None): - """调用 LLM,支持 function calling""" - from openai import OpenAI +def _messages_with_system( + messages_history: List[dict], skill: AiSkill, has_image: bool = False +) -> List[dict]: + return [{"role": "system", "content": _chat_system_prompt(skill, has_image)}] + messages_history + + +def _extract_response_text(response) -> str: + texts: List[str] = [] + for output_item in getattr(response, "output", []) or []: + for content_item in getattr(output_item, "content", []) or []: + if getattr(content_item, "type", "") == "output_text": + text_value = getattr(content_item, "text", "") + if text_value: + texts.append(text_value) + return "\n".join(texts).strip() + + +def _image_data_url(image_base64: str) -> str: + return f"data:image/jpeg;base64,{image_base64}" + + +# ==================== 聊天 / 工具调用 ==================== + + +def call_llm_with_tools( + messages_history: List[dict], + model_override: str = None, + skill_id: Optional[str] = None, + has_image: bool = False, + runtime_config: Optional[RuntimeAiConfig] = None, +): + """调用当前配置的对话模型,支持 function calling。""" use_model = model_override or settings.AI_MODEL - - client = OpenAI( - api_key=settings.AI_API_KEY, - base_url=settings.AI_BASE_URL or "https://api.openai.com/v1", + client = get_ai_client(runtime_config, capability="chat") + skill = get_ai_skill(skill_id) or resolve_ai_skill( + message=str(messages_history[-1].get("content", "")) if messages_history else "", + history_messages=messages_history[:-1], + requested_skill_id=skill_id, + has_image=has_image, ) + messages = _messages_with_system(messages_history, skill, has_image) + tools = _tool_schemas_for_skill(skill) - messages = [{"role": "system", "content": SYSTEM_PROMPT}] + messages_history + log.info(f"{'=' * 60}") + log.info(f"[LLM] provider={get_ai_provider(runtime_config)} model={use_model} source={(runtime_config.source if runtime_config else 'server')}") + log.info(f"[LLM] skill={skill.id}") + log.info(f"[LLM] 消息数量: {len(messages)}") + log.info(f"[LLM] 工具数量: {len(tools)}") - log.info(f"{'='*60}") - log.info(f"[LLM] 调用工具模型: {use_model}{' (override)' if model_override else ''}") - log.info(f"[LLM] 消息数量: {len(messages)} (system + {len(messages_history)} history)") - for i, m in enumerate(messages_history[-5:]): - role = m['role'] - content = m['content'][:120] if m.get('content') else '(empty)' - log.info(f"[LLM] history[-{len(messages_history)-i}] {role}: {content}") - log.info(f"[LLM] 工具数量: {len(PS_TOOLS)}") + request_kwargs = { + "model": use_model, + "messages": messages, + "temperature": 0.3, + } + if tools: + request_kwargs.update( + { + "tools": tools, + "tool_choice": "auto", + "parallel_tool_calls": False, + } + ) - completion = client.chat.completions.create( - model=use_model, - messages=messages, - tools=PS_TOOLS, - tool_choice="auto", - ) + completion = client.chat.completions.create(**request_kwargs) choice = completion.choices[0] message = choice.message + tool_calls = getattr(message, "tool_calls", None) or [] - log.info(f"[LLM] 响应 finish_reason={choice.finish_reason}") - if message.content: - log.info(f"[LLM] 回复文本: {message.content[:150]}...") - if message.tool_calls: - for tc in message.tool_calls: - log.info(f"[LLM] 工具调用: {tc.function.name}({tc.function.arguments[:200]})") - else: - log.info(f"[LLM] 无工具调用") - log.info(f"{'='*60}") - - if message.tool_calls and len(message.tool_calls) > 0: + if tool_calls: tool_calls_data = [] - for tc in message.tool_calls: + for tc in tool_calls: args = {} - if tc.function.arguments: + function = getattr(tc, "function", None) + raw_args = getattr(function, "arguments", "") if function else "" + if raw_args: try: - args = json.loads(tc.function.arguments) + args = json.loads(raw_args) except json.JSONDecodeError: args = {} - tool_calls_data.append({ - "id": tc.id, - "name": tc.function.name, - "display_name": TOOL_DISPLAY_NAMES.get(tc.function.name, tc.function.name), - "args": args, - "status": "pending" - }) - return message.content or "", tool_calls_data + function_name = getattr(function, "name", "") if function else "" + tool_calls_data.append( + { + "id": getattr(tc, "id", function_name), + "name": function_name, + "display_name": TOOL_DISPLAY_NAMES.get(function_name, function_name), + "args": args, + "status": "pending", + } + ) + return getattr(message, "content", "") or "", tool_calls_data, skill.to_public_dict() - return message.content or "", None + return getattr(message, "content", "") or "", None, skill.to_public_dict() -# ==================== Gemini 调用(OpenAI 兼容代理) ==================== - -def call_gemini(messages_history: List[dict], model: str, images_b64: List[str] = None) -> str: - """调用 Gemini(通过第三方代理,OpenAI 兼容格式)""" - from openai import OpenAI as _OpenAI - - if not settings.GEMINI_API_KEY or not settings.GEMINI_BASE_URL: - raise ValueError("GEMINI_API_KEY 或 GEMINI_BASE_URL 未配置") - - client = _OpenAI( - api_key=settings.GEMINI_API_KEY, - base_url=f"{settings.GEMINI_BASE_URL}/v1", - ) - - messages = [] +def call_gemini(messages_history: List[dict], model: str, images_b64: List[str] = None): + """兼容旧接口,内部统一转到当前视觉模型。""" + prompt = "" for msg in messages_history: - role = msg.get("role", "user") - content = msg.get("content", "") - if role not in ("system", "user", "assistant"): - role = "user" - messages.append({"role": role, "content": content}) - + if msg.get("role") == "user" and msg.get("content"): + prompt = str(msg["content"]) if images_b64: - last_user_idx = None - for i in range(len(messages) - 1, -1, -1): - if messages[i]["role"] == "user": - last_user_idx = i - break - if last_user_idx is not None: - text_content = messages[last_user_idx]["content"] - multimodal_content = [] - for img_b64 in images_b64: - multimodal_content.append({ - "type": "image_url", - "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"} - }) - multimodal_content.append({"type": "text", "text": text_content}) - messages[last_user_idx]["content"] = multimodal_content + return call_vision_messages(prompt or "请分析这张图片。", images_b64, model) - log.info(f"{'='*60}") - log.info(f"[Gemini] 调用模型: {model} (OpenAI 兼容)") - log.info(f"[Gemini] 消息数: {len(messages)}, 图片数: {len(images_b64) if images_b64 else 0}") - - completion = client.chat.completions.create(model=model, messages=messages) - result = completion.choices[0].message.content or "" - log.info(f"[Gemini] 回复: {result[:200]}...") - return result + reply, _, _ = call_llm_with_tools(messages_history, model_override=model) + return reply def call_gemini_with_tools(messages_history: List[dict], model: str) -> tuple: - """用 Gemini 做对话(不支持 function calling)""" - full_history = [{"role": "system", "content": SYSTEM_PROMPT}] + messages_history - - log.info(f"{'='*60}") - log.info(f"[Gemini] 调用对话模型: {model}") - for i, m in enumerate(messages_history[-3:]): - log.info(f"[Gemini] history[-{len(messages_history)-i}] {m['role']}: {str(m.get('content',''))[:120]}") - - result = call_gemini(full_history, model) - log.info(f"[Gemini] 回复文本: {result[:150]}...") - return result, None + """兼容旧接口,内部统一转到当前工具调用。""" + return call_llm_with_tools(messages_history, model_override=model) # ==================== 视觉模型 ==================== -def call_vision_llm(user_message: str, image_base64: str, history: List[dict], model_override: str = None) -> str: - """调用视觉模型分析图片(自动路由 Qwen / Gemini)""" + +def call_vision_messages( + user_message: str, + images_b64: List[str], + model_override: str = None, + instructions_override: Optional[str] = None, + runtime_config: Optional[RuntimeAiConfig] = None, +) -> str: + """调用当前视觉模型分析图片。""" use_model = model_override or settings.AI_VISION_MODEL + client = get_ai_client(runtime_config, capability="vision") - if is_gemini_model(use_model): - log.info(f"[Vision] 使用 Gemini 视觉模型: {use_model}") - msgs = [{"role": "system", "content": VISION_PROMPT}] - for h in history[-10:]: - role = h["role"] if h["role"] != "tool" else "user" - msgs.append({"role": role, "content": h["content"]}) - msgs.append({"role": "user", "content": user_message}) - return call_gemini(msgs, use_model, images_b64=[image_base64]) + content = [] + for image_b64 in images_b64: + content.append({"type": "input_image", "image_url": _image_data_url(image_b64)}) + content.append({"type": "input_text", "text": user_message}) - from openai import OpenAI - client = OpenAI(api_key=settings.AI_API_KEY, base_url=settings.AI_BASE_URL or "https://api.openai.com/v1") + response = client.responses.create( + model=use_model, + instructions=instructions_override or VISION_PROMPT, + input=[{"role": "user", "content": content}], + ) - user_content = [ - {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_base64}"}}, - {"type": "text", "text": user_message} - ] - messages = [{"role": "system", "content": VISION_PROMPT}] - for h in history[-10:]: - role = h["role"] if h["role"] != "tool" else "user" - messages.append({"role": role, "content": h["content"]}) - messages.append({"role": "user", "content": user_content}) - - completion = client.chat.completions.create(model=use_model, messages=messages) - return completion.choices[0].message.content or "" + result = _extract_response_text(response) + log.info( + f"[Vision] provider={get_ai_provider(runtime_config)} model={use_model} 输出长度: {len(result)}" + ) + return result -# ==================== 图片编辑/生成模型 ==================== +def call_vision_llm( + user_message: str, + image_base64: str, + history: List[dict], + model_override: str = None, + skill_id: Optional[str] = None, + runtime_config: Optional[RuntimeAiConfig] = None, +) -> str: + """兼容旧接口,调用当前视觉模型。""" + skill = get_ai_skill(skill_id) or resolve_ai_skill( + message=user_message, + history_messages=history, + requested_skill_id=skill_id, + has_image=True, + ) + combined_message = user_message + if history: + recent_text = "\n".join( + str(item.get("content", "")) for item in history[-5:] if item.get("content") + ) + if recent_text: + combined_message = f"历史对话:\n{recent_text}\n\n当前问题:{user_message}" + return call_vision_messages( + combined_message, + [image_base64], + model_override, + instructions_override=_vision_instructions(skill), + runtime_config=runtime_config, + ) -def call_image_model(images_b64: List[str], prompt: str, model_override: str = None) -> tuple: - """调用图片编辑/生成模型(自动路由 DashScope / Gemini),返回 (url_or_datauri, description)""" - import requests as http_requests + +# ==================== 图片编辑 / 生成 ==================== + + +def call_image_model( + images_b64: List[str], + prompt: str, + model_override: str = None, + runtime_config: Optional[RuntimeAiConfig] = None, +) -> tuple: + """调用当前图片模型,返回 (url_or_datauri, description)。""" + result = call_image_model_batch( + images_b64=images_b64, + prompt=prompt, + model_override=model_override, + size="2K", + max_images=1, + stream=False, + runtime_config=runtime_config, + ) + first_image = result["images"][0] + return str(first_image["url"]), "" + + +def _image_response_error_message(payload) -> str: + error = getattr(payload, "error", None) + return str(getattr(error, "message", "") or "").strip() + + +def _image_usage_dict(payload) -> Optional[dict]: + usage = getattr(payload, "usage", None) + if usage is None: + return None + if hasattr(usage, "model_dump"): + return usage.model_dump(mode="json") + if isinstance(usage, dict): + return usage + return None + + +def _image_item_to_result(image_item, index: int) -> dict: + image_url = getattr(image_item, "url", "") or "" + image_b64 = getattr(image_item, "b64_json", "") or "" + image_size = getattr(image_item, "size", "") or "" + + if image_url: + return {"index": index, "url": image_url, "size": image_size} + + if image_b64: + padding = (-len(image_b64)) % 4 + if padding: + image_b64 += "=" * padding + return { + "index": index, + "url": f"data:image/png;base64,{image_b64}", + "size": image_size, + } + + raise ValueError("图片模型返回了空图片结果") + + +def call_image_model_batch( + images_b64: List[str], + prompt: str, + model_override: str = None, + size: str = "2K", + max_images: int = 1, + stream: Optional[bool] = None, + runtime_config: Optional[RuntimeAiConfig] = None, +) -> dict: + """调用当前图片模型,支持单图、参考图生图和连续组图。""" + from volcenginesdkarkruntime.types.images.images import ( + SequentialImageGenerationOptions, + ) use_model = model_override or settings.AI_IMAGE_EDIT_MODEL + client = get_ai_client(runtime_config, capability="image") + image_count = max(1, min(int(max_images or 1), 4)) + should_stream = (image_count > 1) if stream is None else bool(stream) - # ---------- Gemini(OpenAI 兼容代理) ---------- - if is_gemini_model(use_model): - log.info(f"[ImageModel] 使用 Gemini 图片模型: {use_model}") - if not settings.GEMINI_API_KEY or not settings.GEMINI_BASE_URL: - raise ValueError("GEMINI_API_KEY 或 GEMINI_BASE_URL 未配置") - - from openai import OpenAI as _OpenAI - client = _OpenAI(api_key=settings.GEMINI_API_KEY, base_url=f"{settings.GEMINI_BASE_URL}/v1") - - content_parts = [{"type": "text", "text": prompt}] - for img_b64 in images_b64: - content_parts.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}) - - log.info(f"[ImageModel] Gemini OpenAI 兼容, 模型: {use_model}") - log.info(f"[ImageModel] 图片数: {len(images_b64)}, 各图: {[len(b)//1024 for b in images_b64]}KB") - log.info(f"[ImageModel] 提示词: {prompt[:200]}") - - completion = client.chat.completions.create(model=use_model, messages=[{"role": "user", "content": content_parts}]) - result_content = completion.choices[0].message.content or "" - log.info(f"[ImageModel] Gemini 回复长度: {len(result_content)} chars") - - # 提取 base64 图片 - match = re.search(r'!\[.*?\]\((data:image/(\w+);base64,([^)]+))\)', result_content) - if not match: - match = re.search(r'(data:image/(\w+);base64,([A-Za-z0-9+/=]+))', result_content) - if not match: - log.warning(f"[ImageModel] Gemini 响应中无图片,前500字: {result_content[:500]}") - raise ValueError("Gemini 未返回图片,请检查模型是否支持图片生成") - - img_format = match.group(2) - image_b64 = match.group(3) - padding = 4 - len(image_b64) % 4 - if padding != 4: - image_b64 += '=' * padding - - log.info(f"[ImageModel] Gemini 返回图片: image/{img_format}, {len(image_b64)//1024}KB") - _save_debug_image(base64.b64decode(image_b64), f'gemini_output.{img_format}') - - description = re.sub(r'!\[.*?\]\(data:image/[^)]+\)', '', result_content).strip() - return f"data:image/{img_format};base64,{image_b64}", description - - # ---------- DashScope 原生接口 ---------- - api_url = "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation" - content_parts = [] - for img_b64 in images_b64: - content_parts.append({"image": f"data:image/jpeg;base64,{img_b64}"}) - content_parts.append({"text": prompt}) - - payload = { - "model": use_model, - "input": {"messages": [{"role": "user", "content": content_parts}]}, - "parameters": {"n": 1, "watermark": False, "prompt_extend": True} - } - headers = {"Content-Type": "application/json", "Authorization": f"Bearer {settings.AI_API_KEY}"} - - log.info(f"{'='*60}") - log.info(f"[ImageModel] DashScope 原生 API, 模型: {use_model}") - log.info(f"[ImageModel] 图片数: {len(images_b64)}, 各图: {[len(b)//1024 for b in images_b64]}KB") + image_inputs = [_image_data_url(img_b64) for img_b64 in images_b64] or None + image_payload = None + if image_inputs: + image_payload = image_inputs[0] if len(image_inputs) == 1 else image_inputs + log.info(f"{'=' * 60}") + log.info(f"[ImageModel] provider={get_ai_provider(runtime_config)} model={use_model} source={(runtime_config.source if runtime_config else 'server')}") + log.info(f"[ImageModel] 图片数: {len(images_b64)}") + log.info(f"[ImageModel] 目标生成张数: {image_count}") log.info(f"[ImageModel] 提示词: {prompt[:200]}") - for idx, img_b64 in enumerate(images_b64): - _save_debug_image(base64.b64decode(img_b64), f'input_{idx}.jpg') + request_kwargs = { + "model": use_model, + "prompt": prompt, + "image": image_payload, + "response_format": "url", + "size": size or "2K", + "watermark": True, + "stream": should_stream, + "sequential_image_generation": "auto" if image_count > 1 else "disabled", + } + if image_payload is None: + request_kwargs.pop("image") + if image_count > 1: + request_kwargs["sequential_image_generation_options"] = ( + SequentialImageGenerationOptions(max_images=image_count) + ) - resp = http_requests.post(api_url, json=payload, headers=headers, timeout=120) - if resp.status_code != 200: - error_data = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {} - err_msg = error_data.get("message", resp.text[:300]) - log.error(f"[ImageModel] API 错误: {resp.status_code} {err_msg}") - if "data_inspection_failed" in str(error_data): - raise ValueError("图片内容未通过安全审核,请更换图片") - raise ValueError(f"图片模型调用失败({resp.status_code}): {err_msg}") + if should_stream: + stream_response = client.images.generate(**request_kwargs) + images: List[dict] = [] + usage: Optional[dict] = None + errors: List[str] = [] - data = resp.json() - output = data.get("output", {}) - choices = output.get("choices", []) - if not choices: - raise ValueError("模型未返回结果") + for event in stream_response: + if event is None: + continue - content_list = choices[0].get("message", {}).get("content", []) - image_url = None - description = "" - for item in content_list: - if isinstance(item, dict): - if "image" in item: - image_url = item["image"] - elif "text" in item: - description += item["text"] + event_type = str(getattr(event, "type", "") or "") + event_error = _image_response_error_message(event) - if not image_url: - raise ValueError("模型未返回图片") + if event_type.endswith("partial_failed"): + if event_error: + errors.append(event_error) + continue - log.info(f"[ImageModel] 输出图片 URL: {image_url[:120]}...") - try: - out_resp = http_requests.get(image_url, timeout=60) - if out_resp.status_code == 200: - _save_debug_image(out_resp.content, 'output.png') - except Exception: - pass + if event_type.endswith("partial_succeeded"): + try: + images.append( + _image_item_to_result( + event, + index=int(getattr(event, "image_index", len(images))), + ) + ) + except Exception as exc: + errors.append(str(exc)) + continue - return image_url, description + if event_type.endswith("completed"): + usage = _image_usage_dict(event) + + if errors and not images: + raise ValueError(";".join(errors)) + if not images: + raise ValueError("图片模型未返回结果") + + images.sort(key=lambda item: int(item.get("index", 0))) + return {"images": images, "usage": usage, "errors": errors} + + response_kwargs = { + "model": use_model, + "prompt": prompt, + "image": image_payload, + "response_format": "url", + "size": size or "2K", + "stream": False, + "watermark": True, + "sequential_image_generation": "auto" if image_count > 1 else "disabled", + "sequential_image_generation_options": ( + SequentialImageGenerationOptions(max_images=image_count) + if image_count > 1 + else None + ), + } + if image_payload is None: + response_kwargs.pop("image") + + response = client.images.generate(**response_kwargs) + + error_message = _image_response_error_message(response) + if error_message: + raise ValueError(error_message) + + data = getattr(response, "data", None) or [] + if not data: + raise ValueError("图片模型未返回结果") + + images = [_image_item_to_result(item, index=idx) for idx, item in enumerate(data)] + return {"images": images, "usage": _image_usage_dict(response), "errors": []} # ==================== 验证套图效果 ==================== -def verify_pattern_result(garment_b64: str, canvas_b64: str, extra_prompt: str = None, vision_model: str = None) -> str: - """用视觉模型对比原始成衣和套图结果""" - use_model = vision_model or settings.AI_VISION_MODEL - - log.info(f"[Verify] 模型: {use_model}, 成衣: {len(garment_b64)//1024}KB, 画布: {len(canvas_b64)//1024}KB") +def verify_pattern_result( + garment_b64: str, + canvas_b64: str, + extra_prompt: str = None, + vision_model: str = None, + runtime_config: Optional[RuntimeAiConfig] = None, +) -> str: + """用当前视觉模型对比原始成衣和套图结果。""" verify_prompt = ( - "请对比这两张图片:第一张是原始成衣照片,第二张是套图结果。\n" - "验证:1. 花样还原度 2. 裁片覆盖完整度 3. 对齐质量 4. 整体效果。给出评分(1-10)和改进建议。" + "请对比这两张图片:第一张是原始成衣照片,第二张是套图结果。" + "请从花样还原度、裁片覆盖完整度、对齐质量、整体效果四个方面评分,并给出改进建议。" ) if extra_prompt: verify_prompt += f"\n用户补充:{extra_prompt}" - if is_gemini_model(use_model): - return call_gemini( - [{"role": "user", "content": "你是服装套图质量检验专家。"}, {"role": "user", "content": verify_prompt}], - use_model, images_b64=[garment_b64, canvas_b64] - ) - - from openai import OpenAI - client = OpenAI(api_key=settings.AI_API_KEY, base_url=settings.AI_BASE_URL or "https://api.openai.com/v1") - completion = client.chat.completions.create( - model=use_model, - messages=[ - {"role": "system", "content": "你是服装套图质量检验专家。"}, - {"role": "user", "content": [ - {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{garment_b64}"}}, - {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{canvas_b64}"}}, - {"type": "text", "text": verify_prompt}, - ]}, - ], + return call_vision_messages( + verify_prompt, + [garment_b64, canvas_b64], + model_override=vision_model or settings.AI_VISION_MODEL, + runtime_config=runtime_config, ) - return completion.choices[0].message.content or "" # ==================== Mock ==================== + def mock_reply(message: str, has_image: bool = False) -> str: - """未配置 API Key 时的模拟回复""" + """未配置 API Key 时的模拟回复。""" if has_image: - return "【模拟分析】收到图片。AI 分析功能需要配置 AI_API_KEY。" + return "【模拟分析】收到图片。请先在 API 配置页填写你自己的转发 Key。" if "套图" in message: - return "套图功能在「参数预设」页面。先选择花样组和裁片组,再添加规则,最后点击生成。" - return "你好!我是 DesignerCEP AI 助手。你可以问我关于套图、裁片、对齐等功能的问题。" + return "套图功能已就绪,但正式 AI 分析前请先在 API 配置页填写你自己的转发 Key。" + return "你好!我是 DesignerCEP AI 助手。请先在 API 配置页填写你的转发地址和 Key,然后我再帮你执行聊天、看图和套图任务。" -# ==================== 工具函数 ==================== +# ==================== 调试工具 ==================== + def _save_debug_image(data: bytes, filename: str): - """保存调试图片到 debug_images 目录""" - import os, time - debug_dir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'debug_images') + """保存调试图片到 debug_images 目录。""" + import time + + debug_dir = os.path.join( + os.path.dirname(__file__), "..", "..", "..", "debug_images" + ) os.makedirs(debug_dir, exist_ok=True) ts = int(time.time()) - path = os.path.join(debug_dir, f'{ts}_{filename}') + path = os.path.join(debug_dir, f"{ts}_{filename}") try: - with open(path, 'wb') as f: - f.write(data) + with open(path, "wb") as file_obj: + file_obj.write(data) log.info(f"[Debug] 图片已保存: {path}") - except Exception as e: - log.warning(f"[Debug] 保存失败: {e}") + except Exception as exc: + log.warning(f"[Debug] 保存失败: {exc}") diff --git a/Server/app/api/v1/ai_pattern.py b/Server/app/api/v1/ai_pattern.py new file mode 100644 index 0000000..46081ea --- /dev/null +++ b/Server/app/api/v1/ai_pattern.py @@ -0,0 +1,476 @@ +# -*- coding: utf-8 -*- +""" +AI 图案生成接口 +功能:生成预览图、面料图、裁切、细化 +""" + +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel +from typing import Optional +import json, base64, logging +from app.core.config import settings +from app.core.security import get_current_user +from app.db import get_db +from sqlalchemy.orm import Session +from app.models.user import User +from app.api.v1.ai_llm import ( + call_image_model, + call_image_model_batch, + get_runtime_ai_config, + has_ai_config, +) + +log = logging.getLogger(__name__) +if not log.handlers: + log.setLevel(logging.INFO) + log.propagate = False + _h = logging.StreamHandler() + _h.setFormatter( + logging.Formatter("[%(asctime)s][%(name)s][%(levelname)s] %(message)s") + ) + log.addHandler(_h) + +router = APIRouter() + +# ==================== 数据模型 ==================== + + +class GeneratePatternRequest(BaseModel): + image_base64: str + canvas_base64: Optional[str] = None + prompt: Optional[str] = None + image_edit_model: Optional[str] = None + + +class GenerateFabricRequest(BaseModel): + image_base64: str + fabric_info: dict + image_edit_model: Optional[str] = None + + +class GenerateDesignImagesRequest(BaseModel): + prompt: str + reference_image_base64: Optional[str] = None + count: int = 1 + size: Optional[str] = "2K" + image_edit_model: Optional[str] = None + + +class CropPieceRequest(BaseModel): + preview_base64: str + canvas_width: int + canvas_height: int + piece_left: float + piece_top: float + piece_width: float + piece_height: float + piece_name: str + padding: float = 0.15 + + +class RefinePieceRequest(BaseModel): + cropped_base64: str + piece_name: str + pattern_type: str = "fill_pattern" + aspect_ratio: Optional[str] = None + piece_width: Optional[int] = None + piece_height: Optional[int] = None + prompt: Optional[str] = None + image_edit_model: Optional[str] = None + + +class ExtractPieceRequest(BaseModel): + preview_base64: str + piece_name: str + piece_description: str = "" + prompt: Optional[str] = None + + +# ==================== 图案生成接口 ==================== + + +@router.post("/ai/generate-preview") +async def generate_preview( + data: GeneratePatternRequest, + current_username: str = Depends(get_current_user), + db: Session = Depends(get_db), +): + """阶段1:成衣照片 + 裁片截图 → AI 生成带轮廓的花样预览图""" + log.info(f"{'=' * 60}") + log.info( + f"[Preview] 收到预览请求, 成衣图: {len(data.image_base64) // 1024}KB, 裁片图: {len(data.canvas_base64) // 1024 if data.canvas_base64 else 0}KB" + ) + if data.prompt: + log.info(f"[Preview] 自定义提示词: {data.prompt[:150]}") + user = db.query(User).filter(User.username == current_username).first() + runtime_ai_config = get_runtime_ai_config(user) + effective_image_model = ( + data.image_edit_model + or (user.ai_image_model if user else None) + or settings.AI_IMAGE_EDIT_MODEL + ) + if not has_ai_config(runtime_ai_config) or not effective_image_model: + raise HTTPException(400, "请先在 API 配置页填写转发 Key,或联系管理员配置默认 AI_API_KEY") + + try: + images = [data.image_base64] + if data.canvas_base64: + images.append(data.canvas_base64) + + default_prompt = ( + ( + "Image 1 是一件成衣照片,Image 2 是裁片轮廓图。" + "请将 Image 1 成衣上的面料花样提取出来,填充到 Image 2 的每个裁片轮廓内。" + "要求:保持花样颜色、比例、方向一致;保留裁片轮廓线;不要改变裁片排列位置。" + ) + if data.canvas_base64 + else ("请从这件成衣中提取面料花样,生成一张干净的花样平铺图。") + ) + + result_url, desc = call_image_model( + images_b64=images, + prompt=data.prompt or default_prompt, + model_override=effective_image_model, + runtime_config=runtime_ai_config, + ) + return {"code": 200, "data": {"image_url": result_url, "description": desc}} + except Exception as e: + log.error(f"预览生成失败: {e}", exc_info=True) + err_msg = str(e) + if "data_inspection_failed" in err_msg or "inappropriate" in err_msg: + raise HTTPException( + 400, + "图片内容未通过平台安全审核,请更换图片后重试(避免使用含版权角色/敏感内容的图片)", + ) + raise HTTPException(500, f"预览生成失败: {err_msg}") + + +@router.post("/ai/generate-fabric") +async def generate_fabric( + data: GenerateFabricRequest, + current_username: str = Depends(get_current_user), + db: Session = Depends(get_db), +): + """all_over 模式:根据成衣照片 + fabric_info 生成面料平铺图""" + log.info(f"{'=' * 60}") + log.info(f"[Fabric] 生成面料图, 成衣: {len(data.image_base64) // 1024}KB") + log.info( + f"[Fabric] fabric_info: {json.dumps(data.fabric_info, ensure_ascii=False)}" + ) + user = db.query(User).filter(User.username == current_username).first() + runtime_ai_config = get_runtime_ai_config(user) + effective_image_model = ( + data.image_edit_model + or (user.ai_image_model if user else None) + or settings.AI_IMAGE_EDIT_MODEL + ) + if not has_ai_config(runtime_ai_config) or not effective_image_model: + raise HTTPException(400, "请先在 API 配置页填写转发 Key,或联系管理员配置默认 AI_API_KEY") + + try: + fi = data.fabric_info + prompt = ( + f"Please generate a high-resolution flat fabric pattern image based on this garment photo.\n" + f"\nRequirements:\n" + f"1. Output a RECTANGULAR flat fabric image (not garment shape, just the fabric laid flat)\n" + f"2. Fabric type: {fi.get('fabric_type', 'digital print')}\n" + f"3. Pattern elements: {fi.get('pattern_elements', 'floral')}\n" + f"4. Pattern direction: {fi.get('pattern_direction', 'uniform')}\n" + f"5. Color transition: {fi.get('color_transition', 'none')}\n" + f"6. Pattern density: {fi.get('density', 'medium')}\n" + f"7. Remove all garment wrinkles/shadows - show flat fabric only\n" + f"8. The pattern must be clear, colors accurate, suitable for garment piece overlay\n" + f"9. Aspect ratio: 3:4\n" + f"10. The image should look like a piece of fabric photographed from directly above on a flat surface" + ) + + log.info(f"[Fabric] 提示词: {prompt[:200]}") + + result_url, desc = call_image_model( + images_b64=[data.image_base64], + prompt=prompt, + model_override=effective_image_model, + runtime_config=runtime_ai_config, + ) + + log.info(f"[Fabric] 面料图生成完成") + return {"code": 200, "data": {"fabric_url": result_url, "description": desc}} + + except Exception as e: + log.error(f"面料图生成失败: {e}", exc_info=True) + raise HTTPException(500, f"面料图生成失败: {str(e)}") + + +@router.post("/ai/generate-design-images") +async def generate_design_images( + data: GenerateDesignImagesRequest, + current_username: str = Depends(get_current_user), + db: Session = Depends(get_db), +): + """通用豆包出图:支持纯文生图和基于参考图的连续组图。""" + log.info(f"{'=' * 60}") + log.info( + f"[DesignImage] 通用出图, 参考图={'是' if data.reference_image_base64 else '否'}, count={data.count}, size={data.size or '2K'}" + ) + user = db.query(User).filter(User.username == current_username).first() + runtime_ai_config = get_runtime_ai_config(user) + effective_image_model = ( + data.image_edit_model + or (user.ai_image_model if user else None) + or settings.AI_IMAGE_EDIT_MODEL + ) + if not has_ai_config(runtime_ai_config) or not effective_image_model: + raise HTTPException(400, "请先在 API 配置页填写转发 Key,或联系管理员配置默认 AI_API_KEY") + + prompt = (data.prompt or "").strip() + if not prompt: + raise HTTPException(400, "prompt 不能为空") + + try: + count = max(1, min(int(data.count or 1), 4)) + images_b64 = [data.reference_image_base64] if data.reference_image_base64 else [] + result = call_image_model_batch( + images_b64=images_b64, + prompt=prompt, + model_override=effective_image_model, + size=data.size or "2K", + max_images=count, + stream=(count > 1), + runtime_config=runtime_ai_config, + ) + images = result.get("images", []) + return { + "code": 200, + "data": { + "images": images, + "image_urls": [item["url"] for item in images], + "count": len(images), + "used_reference_image": bool(data.reference_image_base64), + "usage": result.get("usage"), + }, + } + except Exception as e: + log.error(f"通用出图失败: {e}", exc_info=True) + err_msg = str(e) + if "data_inspection_failed" in err_msg or "inappropriate" in err_msg: + raise HTTPException(400, "图片内容未通过平台安全审核,请更换提示词或参考图") + raise HTTPException(500, f"通用出图失败: {err_msg}") + + +@router.post("/ai/crop-piece") +async def crop_piece( + data: CropPieceRequest, current_username: str = Depends(get_current_user) +): + """从预览图中按坐标裁切指定裁片区域(Pillow,像素级精准)""" + log.info(f"{'=' * 60}") + log.info(f"[CropPiece] 裁切: {data.piece_name}") + log.info( + f"[CropPiece] 画布: {data.canvas_width}x{data.canvas_height}, 裁片区域: ({data.piece_left},{data.piece_top}) {data.piece_width}x{data.piece_height}" + ) + + try: + result_b64 = _crop_piece_from_preview(data) + return { + "code": 200, + "data": {"cropped_base64": result_b64, "piece_name": data.piece_name}, + } + except Exception as e: + log.error(f"裁切失败: {e}", exc_info=True) + raise HTTPException(500, f"裁切失败: {str(e)}") + + +@router.post("/ai/refine-piece") +async def refine_piece( + data: RefinePieceRequest, + current_username: str = Depends(get_current_user), + db: Session = Depends(get_db), +): + """AI 细化裁切图:根据图案类型生成不同提示词""" + log.info(f"{'=' * 60}") + log.info( + f"[RefinePiece] 细化: {data.piece_name}, 类型: {data.pattern_type}, 比例: {data.aspect_ratio}" + ) + log.info( + f"[RefinePiece] 裁片尺寸: {data.piece_width}x{data.piece_height}, 图片: {len(data.cropped_base64) // 1024}KB" + ) + + user = db.query(User).filter(User.username == current_username).first() + runtime_ai_config = get_runtime_ai_config(user) + effective_image_model = ( + data.image_edit_model + or (user.ai_image_model if user else None) + or settings.AI_IMAGE_EDIT_MODEL + ) + if not has_ai_config(runtime_ai_config) or not effective_image_model: + raise HTTPException(400, "请先在 API 配置页填写转发 Key,或联系管理员配置默认 AI_API_KEY") + + try: + size_hint = "" + if data.aspect_ratio: + size_hint = f"输出图片宽高比必须为 {data.aspect_ratio}。" + if data.piece_width and data.piece_height: + size_hint += f"输出尺寸至少 {int(data.piece_width * 1.15)}x{int(data.piece_height * 1.15)} 像素。" + + if data.pattern_type == "fill_pattern": + prompt = data.prompt or ( + f"这是从服装裁片中裁切出来的花型图案区域。" + f"请将它处理为一张干净的矩形花型图:" + f"去掉所有轮廓线、标签文字和边缘空白;" + f"花型图案要完整铺满整个矩形,无空白边距;" + f"向四周自然延展重复纹样,保持花型连续无接缝;" + f"保持花样的颜色和细节清晰。{size_hint}" + ) + elif data.pattern_type == "theme_pattern": + prompt = data.prompt or ( + f"这是从服装裁片中裁切出来的主题图案区域(如卡通人物、Logo、印花)。" + f"请提取其中的主题图案,输出为纯白色背景(#FFFFFF)上的主题图案:" + f"主题图案要完整清晰,保持原始颜色和细节;" + f"背景必须是纯白色(#FFFFFF),不要任何其他底色或纹理;" + f"去掉轮廓线和标签文字;主题图案在画面中居中放置。{size_hint}" + ) + elif data.pattern_type == "mixed_fill": + prompt = data.prompt or ( + f"这是从服装裁片中裁切出来的区域,包含主题图案和花型底纹。" + f"请只提取底部的花型/纹理图案,去掉上面的主题图案(人物/Logo):" + f"用底纹自然填充主题图案原来的位置;" + f"花型要完整铺满整个矩形,保持纹样连续;" + f"去掉轮廓线和标签文字。{size_hint}" + ) + elif data.pattern_type == "mixed_theme": + prompt = data.prompt or ( + f"这是从服装裁片中裁切出来的区域,包含主题图案和花型底纹。" + f"请只提取主题图案(人物/Logo/大面积印花),输出为纯白色背景(#FFFFFF):" + f"主题图案要完整清晰,保持原始颜色和细节;" + f"背景必须是纯白色(#FFFFFF),去掉所有底纹;" + f"去掉轮廓线和标签文字;主题图案在画面中居中放置。{size_hint}" + ) + else: + prompt = ( + data.prompt + or f"细化这张图片,去掉轮廓线和标签文字,保持清晰。{size_hint}" + ) + + log.info(f"[RefinePiece] 提示词: {prompt[:200]}") + + result_url, desc = call_image_model( + images_b64=[data.cropped_base64], + prompt=prompt, + model_override=effective_image_model, + runtime_config=runtime_ai_config, + ) + return { + "code": 200, + "data": { + "refined_url": result_url, + "piece_name": data.piece_name, + "pattern_type": data.pattern_type, + }, + } + except Exception as e: + log.error(f"细化失败: {e}", exc_info=True) + err_msg = str(e) + if "data_inspection_failed" in err_msg: + raise HTTPException(400, "图片内容未通过安全审核") + raise HTTPException(500, f"细化失败: {err_msg}") + + +@router.post("/ai/extract-piece-pattern") +async def extract_piece_pattern( + data: ExtractPieceRequest, + current_username: str = Depends(get_current_user), + db: Session = Depends(get_db), +): + """阶段2:从预览图中提取指定裁片区域 → 输出矩形花样图""" + log.info(f"{'=' * 60}") + log.info( + f"[ExtractPiece] 裁片: {data.piece_name}, 描述: '{data.piece_description}', 预览图: {len(data.preview_base64) // 1024}KB" + ) + user = db.query(User).filter(User.username == current_username).first() + runtime_ai_config = get_runtime_ai_config(user) + effective_image_model = ( + getattr(data, "image_edit_model", None) + or (user.ai_image_model if user else None) + or settings.AI_IMAGE_EDIT_MODEL + ) + if not has_ai_config(runtime_ai_config) or not effective_image_model: + raise HTTPException(400, "请先在 API 配置页填写转发 Key,或联系管理员配置默认 AI_API_KEY") + + try: + prompt = data.prompt or ( + f"这是一张服装裁片花样预览图,包含多个裁片。" + f"请提取其中【{data.piece_name}】的花样区域" + f"{'(' + data.piece_description + ')' if data.piece_description else ''}," + f"将该区域的花样输出为一张完整的矩形图片。" + f"要求:只保留花样内容,去掉轮廓线,填满整个矩形,保持清晰。" + ) + result_url, desc = call_image_model( + images_b64=[data.preview_base64], + prompt=prompt, + model_override=effective_image_model, + runtime_config=runtime_ai_config, + ) + return { + "code": 200, + "data": { + "pattern_url": result_url, + "piece_name": data.piece_name, + "description": desc, + }, + } + except Exception as e: + log.error(f"裁片提取失败: {e}", exc_info=True) + raise HTTPException(500, f"裁片提取失败: {str(e)}") + + +# ==================== 工具函数 ==================== + + +def _crop_piece_from_preview(data: CropPieceRequest) -> str: + """用 Pillow 从预览图中按 PS 坐标裁切指定区域(带出血线)""" + from PIL import Image + import io + + img_bytes = base64.b64decode(data.preview_base64) + preview_img = Image.open(io.BytesIO(img_bytes)) + pw, ph = preview_img.size + + scale_x = pw / data.canvas_width + scale_y = ph / data.canvas_height + + bleed_x = data.piece_width * data.padding + bleed_y = data.piece_height * data.padding + + left = max(0, (data.piece_left - bleed_x) * scale_x) + top = max(0, (data.piece_top - bleed_y) * scale_y) + right = min(pw, (data.piece_left + data.piece_width + bleed_x) * scale_x) + bottom = min(ph, (data.piece_top + data.piece_height + bleed_y) * scale_y) + + log.info(f"[CropPiece] PS 画布: {data.canvas_width}x{data.canvas_height}") + log.info(f"[CropPiece] 预览图: {pw}x{ph}") + log.info(f"[CropPiece] 缩放比: x={scale_x:.4f}, y={scale_y:.4f}") + log.info( + f"[CropPiece] PS 裁片区域: ({data.piece_left},{data.piece_top}) {data.piece_width}x{data.piece_height}" + ) + log.info( + f"[CropPiece] 出血线: {data.padding * 100:.0f}% → x±{bleed_x:.0f}px, y±{bleed_y:.0f}px" + ) + log.info( + f"[CropPiece] 预览图裁切: ({left:.0f},{top:.0f})-({right:.0f},{bottom:.0f}) = {right - left:.0f}x{bottom - top:.0f}px" + ) + + cropped = preview_img.crop((int(left), int(top), int(right), int(bottom))) + + import os, time + + debug_dir = os.path.join( + os.path.dirname(__file__), "..", "..", "..", "debug_images" + ) + os.makedirs(debug_dir, exist_ok=True) + ts = int(time.time()) + debug_path = os.path.join(debug_dir, f"{ts}_crop_{data.piece_name}.png") + cropped.save(debug_path) + log.info(f"[CropPiece] 裁切结果已保存: {debug_path}") + + buf = io.BytesIO() + cropped.save(buf, format="PNG") + return base64.b64encode(buf.getvalue()).decode() diff --git a/Server/app/api/v1/ai_skills.py b/Server/app/api/v1/ai_skills.py new file mode 100644 index 0000000..92b88b2 --- /dev/null +++ b/Server/app/api/v1/ai_skills.py @@ -0,0 +1,636 @@ +""" +AI 技能注册表 +为 AI 助手提供可复用的任务模式、工作流和工具边界。 +""" + +from dataclasses import dataclass +from typing import Iterable, Optional + + +@dataclass(frozen=True) +class AiSkill: + id: str + name: str + description: str + mode: str + keywords: tuple[str, ...] + allowed_tools: tuple[str, ...] + workflow: tuple[str, ...] + guardrails: tuple[str, ...] + success_criteria: tuple[str, ...] + planning_style: str + image_hint: str = "" + origin: str = "" + origin_url: str = "" + upstream_skill: str = "" + triage_questions: tuple[str, ...] = () + deliverables: tuple[str, ...] = () + execution_notes: tuple[str, ...] = () + + def to_public_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "description": self.description, + "mode": self.mode, + "planning_style": self.planning_style, + "image_hint": self.image_hint, + "origin": self.origin, + "origin_url": self.origin_url, + "upstream_skill": self.upstream_skill, + } + + +AUTO_SKILL = { + "id": "auto", + "name": "智能自动选择", + "description": "按当前问题、图片和 Photoshop 上下文,自动切换最合适的技能。", + "mode": "auto", + "planning_style": "先判断任务类型,再切到最匹配的专家技能。", + "image_hint": "适合不知道该选哪个技能时使用。", + "origin": "内置技能路由", + "origin_url": "", + "upstream_skill": "", +} + +OPENCLAW_REPO_URL = "https://github.com/openclaw/skills" +OPENCLAW_PS_AUTOMATOR_URL = ( + "https://github.com/openclaw/skills/tree/main/skills/abdul-karim-mia/photoshop-automator" +) +OPENCLAW_UI_UX_PRO_URL = ( + "https://github.com/openclaw/skills/tree/main/skills/15349185792/ui-ux-pro-max-0-1-0" +) +OPENCLAW_UI_DESIGNER_URL = ( + "https://github.com/openclaw/skills/tree/main/skills/1999azzar/ui-designer-skill" +) + + +SKILLS: tuple[AiSkill, ...] = ( + AiSkill( + id="ps-generalist", + name="PS 全能助手", + description="处理一般 Photoshop 操作、图层整理、文档检查、稳定执行和轻量自动化任务。", + mode="tool_agent", + keywords=( + "ps", + "photoshop", + "图层", + "文档", + "画布", + "对齐", + "移动", + "旋转", + "缩放", + "文字", + "保存", + "导出", + "出图", + "效果图", + "不会", + "怎么做", + "怎么操作", + "如何操作", + "教我", + "报错", + "错误", + "失败", + "没反应", + "不能", + "无法", + "为什么", + ), + allowed_tools=( + "get_document_info", + "get_layer_structure", + "get_active_layer_info", + "list_all_layer_names", + "list_documents", + "switch_document", + "create_layer", + "rename_layer", + "delete_layer", + "duplicate_layer", + "create_layer_group", + "move_layer_to_group", + "group_layers", + "move_layer", + "resize_layer", + "rotate_layer", + "flip_layer", + "align_layers", + "align_layers_tool", + "distribute_layers", + "set_layer_visible", + "set_layer_opacity", + "set_layer_blend_mode", + "bring_to_front", + "send_to_back", + "resize_canvas", + "create_text_layer", + "set_text_content", + "set_text_color", + "set_text_size", + "generate_design_images", + "save_document", + "save_document_as", + "undo", + "close_document", + ), + workflow=( + "先确认当前活动文档、目标图层和任务边界,再决定操作路径。", + "优先用最少的步骤完成任务,不做多余改动,能查就先查。", + "执行后总结结果,并明确告诉用户下一步还能继续做什么。", + ), + guardrails=( + "涉及删除、关闭、覆盖保存时先征求确认。", + "不清楚目标图层时先查询,不要盲改。", + "能在新图层或新图层组完成的改动,不直接破坏原图层。", + "Photoshop 若有模态弹窗或保存窗口,先提醒用户关闭,再继续工具执行。", + "按图层名操作时要求精确匹配,不要自己猜测最像的图层。", + ), + success_criteria=( + "操作路径清晰", + "图层关系不混乱", + "文档仍然可继续编辑", + ), + planning_style="先查活动文档和目标图层,再决定是查询、轻改还是执行一串可回退的 PS 操作。", + image_hint="适合边聊边操作 Photoshop,而不是深度看图分析。", + origin="改造自 OpenClaw 的 photoshop-automator", + origin_url=OPENCLAW_PS_AUTOMATOR_URL, + upstream_skill="openclaw/photoshop-automator", + execution_notes=( + "默认围绕当前活动文档工作;没有文档时先提示用户打开文件。", + "执行链路尽量短、原子化,避免一次堆很多风险操作。", + "遇到文本替换、图层修改这类任务时,先确认名称和对象,再执行。", + "如果用户是在问不会怎么做、为什么失败或下一步怎么操作,先读取当前上下文再答疑,必要时同轮直接执行。", + ), + ), + AiSkill( + id="garment-pattern-mapper", + name="服装套图师", + description="处理服装成衣、裁片、花型、面料和平铺套图任务。", + mode="tool_agent", + keywords=( + "套图", + "裁片", + "成衣", + "服装", + "花型", + "花样", + "印花", + "面料", + "前片", + "后片", + "袖子", + "pattern", + "garment", + ), + allowed_tools=( + "get_document_info", + "get_layer_structure", + "list_all_layer_names", + "identify_pieces", + "generate_garment_preview", + "extract_and_apply_all_pieces", + "verify_pattern_result", + ), + workflow=( + "先判断用户是要识别、预览还是正式套图。", + "需要套图时,先识别裁片和花型模式,再生成预览或直接走 all_over 流程。", + "正式套图后用验证结果总结问题和改进建议。", + ), + guardrails=( + "生成预览后,除非用户明确确认,否则不要直接进入正式套图。", + "识别结果不确定时要先说明,再继续下一步。", + "没有参考图或没有有效裁片上下文时,不要假装已经完成套图。", + ), + success_criteria=( + "裁片识别合理", + "花型方向和比例尽量接近原图", + "流程状态对用户透明", + ), + planning_style="把任务拆成 识别 -> 预览 -> 确认 -> 正式套图 -> 验证 五段式流程。", + image_hint="适合用户上传成衣图、要求识别裁片、生成预览或正式套图时使用。", + ), + AiSkill( + id="ps-layout-designer", + name="PS 排版设计师", + description="处理海报、详情页、主视觉、标题层级、品牌感和版式整理任务。", + mode="tool_agent", + keywords=( + "海报", + "排版", + "版式", + "标题", + "字体", + "封面", + "主视觉", + "详情页", + "banner", + "构图", + "留白", + "层级", + "品牌感", + "视觉方向", + "卖点", + "背景图", + "效果图", + "方案图", + ), + allowed_tools=( + "get_document_info", + "get_layer_structure", + "list_all_layer_names", + "get_active_layer_info", + "create_text_layer", + "set_text_content", + "set_text_color", + "set_text_size", + "create_layer_group", + "group_layers", + "move_layer", + "resize_layer", + "align_layers_tool", + "distribute_layers", + "bring_to_front", + "send_to_back", + "set_layer_opacity", + "set_layer_blend_mode", + "add_stroke", + "add_drop_shadow", + "generate_design_images", + ), + workflow=( + "先分析画布比例、已有图层、信息密度和视觉重心。", + "先给出版式思路,再搭主标题、辅文、卖点和装饰层。", + "最后统一对齐、层级、间距、可读性和品牌感。", + ), + guardrails=( + "涉及大改版式时,优先新建组或新图层,而不是覆盖原设计。", + "没有足够上下文时,先做查询或提出一版轻量方案。", + "不要一次性做太多不可逆操作。", + ), + success_criteria=( + "视觉层级清楚", + "对齐和间距统一", + "画面有主次和留白", + ), + planning_style="先做版式诊断和信息架构,再分步搭建图层,不要一上来就乱动。", + image_hint="适合海报排版、标题优化、版式调整和页面重构。", + origin="改造自 OpenClaw 的 ui-ux-pro-max / ui-designer-skill", + origin_url=OPENCLAW_UI_UX_PRO_URL, + upstream_skill="openclaw/ui-ux-pro-max + ui-designer-skill", + triage_questions=( + "当前作品更偏海报、封面、电商主图还是详情页?", + "这次要优先提升点击率、品牌感、层级清晰度还是信息转化?", + "有没有必须保留的品牌色、主标题、卖点或参考风格?", + ), + deliverables=( + "一句话视觉方向", + "标题/副标题/卖点的层级方案", + "对齐、留白、构图和配色建议", + "对应到 Photoshop 的图层调整动作", + ), + execution_notes=( + "先拆信息优先级,再决定字体大小、位置和装饰强度。", + "尽量在新组内搭结构,保留原稿便于回退和对比。", + "如果上下文不足,先给一版轻量方向,再等用户确认后深化。", + ), + ), + AiSkill( + id="creative-direction-strategist", + name="创意方向设计师", + description="处理参考图拆解、品牌调性、视觉方向、配色与构图方案,把灵感转成可执行设计路线。", + mode="hybrid_chat", + keywords=( + "创意", + "方向", + "灵感", + "调性", + "参考图", + "品牌感", + "构图", + "配色", + "氛围", + "风格方案", + "视觉方向", + "art direction", + "moodboard", + "方向图", + "概念图", + "效果图", + "出图", + "方案图", + "插画", + "画面", + "方向稿", + "素材图", + ), + allowed_tools=( + "get_document_info", + "get_layer_structure", + "list_all_layer_names", + "create_layer_group", + "create_text_layer", + "align_layers_tool", + "add_guide", + "generate_design_images", + ), + workflow=( + "先理解任务目标和参考图里最有价值的视觉线索。", + "再给出 2 到 3 条创意方向,说明各自的调性、构图和适用场景。", + "最后把选中的方向转成 Photoshop 可执行的排版、图层和颜色建议。", + ), + guardrails=( + "不要只给抽象评价,要把风格结论落到可执行动作上。", + "没有足够上下文时,不要假设品牌调性和目标人群。", + "除非用户要求,不直接大改现有图层结构。", + ), + success_criteria=( + "风格判断可信", + "方向方案有区分度", + "建议能直接落回 Photoshop", + ), + planning_style="先做视觉诊断,再给 2 到 3 条方向,再落成可执行的排版和图层建议。", + image_hint="适合上传参考图后,让 AI 像设计师一样拆方向、配色、构图和执行路径。", + origin="改造自 OpenClaw 的 ui-ux-pro-max / ui-designer-skill", + origin_url=OPENCLAW_REPO_URL, + upstream_skill="openclaw/ui-ux-pro-max + ui-designer-skill", + triage_questions=( + "这次更偏品牌升级、活动海报、电商转化还是氛围图?", + "想保留参考图里的哪些东西:配色、构图、质感还是情绪?", + "输出要更稳重、高级、年轻还是更强转化?", + ), + deliverables=( + "2 到 3 条创意方向", + "主视觉、标题、辅助元素的布局思路", + "配色、字体和材质建议", + "进入 Photoshop 后的第一轮动作清单", + ), + execution_notes=( + "有参考图时先拆视觉语言,再给风格方向,不要直接开始操作。", + "没有图片时也要先问清目标和场景,再给方案。", + "当用户确认方向后,再切到排版或修图类技能深入执行。", + ), + ), + AiSkill( + id="product-retoucher", + name="修图精修师", + description="处理抠图、调色、去背、产品主图优化、亮度对比和图层修整任务。", + mode="tool_agent", + keywords=( + "修图", + "精修", + "抠图", + "去背", + "调色", + "亮度", + "对比度", + "磨皮", + "主图", + "高光", + "阴影", + "retouch", + ), + allowed_tools=( + "get_document_info", + "get_layer_structure", + "get_active_layer_info", + "duplicate_layer", + "create_layer", + "rasterize_layer", + "adjust_brightness_contrast", + "adjust_levels", + "adjust_hsl", + "auto_levels", + "auto_contrast", + "desaturate", + "gaussian_blur", + "create_clipping_mask", + "release_clipping_mask", + "add_layer_mask", + "delete_layer_mask", + "fill_selection", + "inverse_selection", + "feather_selection", + "copy_merged_to_new_layer", + "set_layer_blend_mode", + "set_layer_opacity", + "save_document", + "save_document_as", + ), + workflow=( + "先判断任务属于结构修图、颜色修正还是产品精修。", + "优先做可回退的副本或蒙版,再进行调整。", + "最终说明改动方向和还可以继续优化的点。", + ), + guardrails=( + "不要直接破坏唯一原图层。", + "没有明确选区或图层时先查询上下文。", + "涉及批量覆盖时先提醒用户风险。", + ), + success_criteria=( + "改动可回退", + "主体更清晰", + "颜色和层次更稳定", + ), + planning_style="优先非破坏性修图:复制、蒙版、调整,再视情况合并。", + image_hint="适合产品主图、商品精修、调色和去背类任务。", + ), + AiSkill( + id="visual-analysis-advisor", + name="看图分析师", + description="处理上传图片后的版型、风格、配色、元素拆解、设计建议和参考分析。", + mode="vision_chat", + keywords=( + "看图", + "分析", + "你看见", + "这张图", + "参考图", + "风格", + "版型", + "配色", + "元素", + "灵感", + "建议", + "评价", + ), + allowed_tools=(), + workflow=( + "先解释看到了什么,再拆解风格、结构和重点元素。", + "如果用户有执行目标,再把分析转成可落地的 Photoshop 操作建议。", + ), + guardrails=( + "不要虚构已经执行过 Photoshop 操作。", + "分析不确定时用概率表达,不要装作绝对正确。", + ), + success_criteria=( + "描述准确", + "建议可执行", + "重点突出", + ), + planning_style="先看图,再给结论,最后给下一步操作建议。", + image_hint="适合用户上传图片后问“你看见了什么”“这个版型/风格怎么做”。", + origin="改造自 OpenClaw 的 ui-designer-skill", + origin_url=OPENCLAW_UI_DESIGNER_URL, + upstream_skill="openclaw/ui-designer-skill", + deliverables=( + "图片内容描述", + "风格、构图、配色和元素拆解", + "可以继续在 Photoshop 落地的建议", + ), + ), +) + + +SKILL_BY_ID = {skill.id: skill for skill in SKILLS} + + +def list_ai_skills_public() -> list[dict]: + return [AUTO_SKILL, *(skill.to_public_dict() for skill in SKILLS)] + + +def get_ai_skill(skill_id: Optional[str]) -> Optional[AiSkill]: + if not skill_id or skill_id == "auto": + return None + return SKILL_BY_ID.get(skill_id) + + +def _history_text(history_messages: Iterable[dict]) -> str: + parts: list[str] = [] + for item in history_messages: + content = str(item.get("content", "")).strip() + if content: + parts.append(content) + return "\n".join(parts[-6:]) + + +def _score_skill(skill: AiSkill, text: str, has_image: bool) -> int: + lowered = text.lower() + score = 0 + + for keyword in skill.keywords: + if keyword.lower() in lowered: + score += 3 + + if has_image and skill.mode == "vision_chat": + score += 2 + if has_image and skill.id == "garment-pattern-mapper": + score += 1 + + if skill.id == "garment-pattern-mapper" and any( + token in lowered for token in ("套图", "裁片", "成衣", "花型", "面料", "pattern") + ): + score += 6 + if skill.id == "ps-layout-designer" and any( + token in lowered + for token in ( + "排版", + "海报", + "标题", + "版式", + "封面", + "banner", + "品牌感", + "卖点", + "背景图", + "方案图", + ) + ): + score += 5 + if skill.id == "creative-direction-strategist" and any( + token in lowered + for token in ( + "创意", + "方向", + "灵感", + "调性", + "参考图", + "品牌感", + "构图", + "配色", + "氛围", + "风格方案", + "方向图", + "效果图", + "概念图", + "方案图", + "背景图", + "出图", + "生成一张", + "生成四张", + "生成一组", + "插画", + "画面", + "方向稿", + "连贯", + ) + ): + score += 6 + if has_image and skill.id == "creative-direction-strategist": + score += 2 + if skill.id == "product-retoucher" and any( + token in lowered for token in ("修图", "去背", "抠图", "调色", "精修", "主图") + ): + score += 5 + if skill.id == "visual-analysis-advisor" and has_image and any( + token in lowered for token in ("分析", "看", "风格", "版型", "你看见", "建议", "元素") + ): + score += 6 + if skill.id == "ps-generalist" and any( + token in lowered + for token in ( + "不会", + "怎么做", + "怎么操作", + "如何操作", + "教我", + "帮我操作", + "帮我弄", + "为什么", + "报错", + "错误", + "失败", + "没反应", + "不能", + "无法", + "下一步", + "该怎么", + ) + ): + score += 7 + if skill.mode == "tool_agent" and any( + token in lowered for token in ("帮我做", "直接做", "你来做", "顺手做", "帮我操作") + ): + score += 4 + + return score + + +def resolve_ai_skill( + message: str, + history_messages: Optional[Iterable[dict]] = None, + requested_skill_id: Optional[str] = None, + has_image: bool = False, +) -> AiSkill: + explicit_skill = get_ai_skill(requested_skill_id) + if explicit_skill: + return explicit_skill + + combined_text = f"{_history_text(history_messages or [])}\n{message or ''}".strip() + combined_text = combined_text or "" + + best_skill = SKILL_BY_ID["ps-generalist"] + best_score = -1 + + for skill in SKILLS: + score = _score_skill(skill, combined_text, has_image) + if score > best_score: + best_skill = skill + best_score = score + + if has_image and best_score <= 1: + return SKILL_BY_ID["visual-analysis-advisor"] + + return best_skill diff --git a/Server/app/api/v1/ai_tools.py b/Server/app/api/v1/ai_tools.py index 132a043..be17e73 100644 --- a/Server/app/api/v1/ai_tools.py +++ b/Server/app/api/v1/ai_tools.py @@ -13,24 +13,16 @@ PS_TOOLS = [ "function": { "name": "get_document_info", "description": "获取当前 Photoshop 文档信息(名称、尺寸、分辨率)", - "parameters": { - "type": "object", - "properties": {}, - "required": [] - } - } + "parameters": {"type": "object", "properties": {}, "required": []}, + }, }, { "type": "function", "function": { "name": "get_layer_structure", "description": "获取当前文档的图层结构树(所有顶层组和子图层)", - "parameters": { - "type": "object", - "properties": {}, - "required": [] - } - } + "parameters": {"type": "object", "properties": {}, "required": []}, + }, }, { "type": "function", @@ -40,14 +32,11 @@ PS_TOOLS = [ "parameters": { "type": "object", "properties": { - "name": { - "type": "string", - "description": "新图层的名称" - } + "name": {"type": "string", "description": "新图层的名称"} }, - "required": ["name"] - } - } + "required": ["name"], + }, + }, }, { "type": "function", @@ -56,39 +45,26 @@ PS_TOOLS = [ "description": "重命名当前选中的图层", "parameters": { "type": "object", - "properties": { - "new_name": { - "type": "string", - "description": "新名称" - } - }, - "required": ["new_name"] - } - } + "properties": {"new_name": {"type": "string", "description": "新名称"}}, + "required": ["new_name"], + }, + }, }, { "type": "function", "function": { "name": "delete_layer", "description": "删除当前选中的图层", - "parameters": { - "type": "object", - "properties": {}, - "required": [] - } - } + "parameters": {"type": "object", "properties": {}, "required": []}, + }, }, { "type": "function", "function": { "name": "duplicate_layer", "description": "复制当前选中的图层", - "parameters": { - "type": "object", - "properties": {}, - "required": [] - } - } + "parameters": {"type": "object", "properties": {}, "required": []}, + }, }, { "type": "function", @@ -100,25 +76,28 @@ PS_TOOLS = [ "properties": { "type": { "type": "string", - "enum": ["left", "right", "centerH", "top", "bottom", "centerV"], - "description": "对齐方式:left=左对齐, right=右对齐, centerH=水平居中, top=顶对齐, bottom=底对齐, centerV=垂直居中" + "enum": [ + "left", + "right", + "centerH", + "top", + "bottom", + "centerV", + ], + "description": "对齐方式:left=左对齐, right=右对齐, centerH=水平居中, top=顶对齐, bottom=底对齐, centerV=垂直居中", } }, - "required": ["type"] - } - } + "required": ["type"], + }, + }, }, { "type": "function", "function": { "name": "merge_layers", "description": "合并当前选中的图层", - "parameters": { - "type": "object", - "properties": {}, - "required": [] - } - } + "parameters": {"type": "object", "properties": {}, "required": []}, + }, }, { "type": "function", @@ -127,15 +106,10 @@ PS_TOOLS = [ "description": "创建图层组(如果不存在)", "parameters": { "type": "object", - "properties": { - "name": { - "type": "string", - "description": "图层组名称" - } - }, - "required": ["name"] - } - } + "properties": {"name": {"type": "string", "description": "图层组名称"}}, + "required": ["name"], + }, + }, }, { "type": "function", @@ -145,14 +119,11 @@ PS_TOOLS = [ "parameters": { "type": "object", "properties": { - "group_name": { - "type": "string", - "description": "目标图层组名称" - } + "group_name": {"type": "string", "description": "目标图层组名称"} }, - "required": ["group_name"] - } - } + "required": ["group_name"], + }, + }, }, # ==================== 套图工具 ==================== { @@ -160,30 +131,51 @@ PS_TOOLS = [ "function": { "name": "identify_pieces", "description": "识别裁片部位。截取当前PS画布并分析每个裁片图层的形状和位置,用视觉AI识别哪个图层是前片、后片、袖子等。应在套图前先调用,以建立图层名到裁片部位的对应关系。", - "parameters": { - "type": "object", - "properties": {}, - "required": [] - } - } + "parameters": {"type": "object", "properties": {}, "required": []}, + }, }, { "type": "function", "function": { "name": "generate_garment_preview", "description": "【阶段1-生成预览】将用户上传的成衣照片中的花样,填充到 PS 文档的裁片轮廓上,生成一张带轮廓的花样预览图。预览图会显示在聊天中,供用户确认效果。需要用户已上传成衣图片。", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "generate_design_images", + "description": "通用豆包出图。按提示词生成设计图、方向图、概念图、背景图或海报素材。支持纯文生图;如果当前聊天里已上传参考图,可设置 use_reference_image=true 基于该图继续出图。count 大于 1 时会返回 1 到 4 张连贯方案图。", "parameters": { "type": "object", - "properties": {}, - "required": [] - } - } + "properties": { + "prompt": { + "type": "string", + "description": "生成要求。尽量写清主体、风格、构图、配色、材质、光线、氛围和用途。", + }, + "count": { + "type": "integer", + "description": "生成数量,1 到 4,默认 1。", + }, + "use_reference_image": { + "type": "boolean", + "description": "是否使用当前聊天最近上传的图片作为参考图。", + }, + "size": { + "type": "string", + "description": "输出尺寸,默认 2K。", + }, + }, + "required": ["prompt"], + }, + }, }, { "type": "function", "function": { "name": "extract_and_apply_all_pieces", - "description": "【阶段2-提取套图】用户确认预览 OK 后调用。根据 identify_pieces 的分析结果,对每个裁片执行不同操作:solid=PS纯色填充;fill_pattern=AI提取花型铺满;theme_pattern=底层纯色填充+上层AI提取主题图案(白底+正片叠底);mixed_pattern=底层AI提取花型+上层AI提取主题图案(白底+正片叠底)。", + "description": "【阶段2-正式套图】用户确认预览OK后调用。自动根据 pattern_mode 选择策略:all_over模式=生成一张面料图覆盖所有裁片(最快);placement模式=逐片处理。all_over模式下不需要先生成预览图(generate_garment_preview),直接调用即可。", "parameters": { "type": "object", "properties": { @@ -192,485 +184,970 @@ PS_TOOLS = [ "items": { "type": "object", "properties": { - "name": {"type": "string", "description": "裁片图层名称"}, - "type": {"type": "string", "enum": ["solid", "fill_pattern", "theme_pattern", "mixed_pattern"], "description": "图案类型:solid=纯色;fill_pattern=花型铺满;theme_pattern=主题图案+纯色底;mixed_pattern=主题图案+花型底纹"}, - "color": {"type": "string", "description": "颜色 hex 值。solid 和 theme_pattern 必须提供(theme_pattern 的 color 是底色)"}, - "description": {"type": "string", "description": "图案内容描述"} - }, - "required": ["name", "type"] + "name": { + "type": "string", + "description": "裁片图层名称(使用实际图层名)", + }, + "type": { + "type": "string", + "enum": [ + "solid", + "all_over", + "fill_pattern", + "theme_pattern", + "mixed_pattern", + ], + "description": "图案类型", + }, + "color": { + "type": "string", + "description": "颜色 hex(solid/theme_pattern 必填)", + }, + "description": { + "type": "string", + "description": "图案描述", + }, + }, + "required": ["name", "type"], }, - "description": "裁片列表,type 和 color 来自 identify_pieces 的分析结果" + "description": "裁片列表,type/color 来自 identify_pieces 的分析结果", } }, - "required": ["pieces"] - } - } + "required": ["pieces"], + }, + }, }, { "type": "function", "function": { "name": "verify_pattern_result", "description": "验证套图效果。自动截取当前 PS 画布与原始成衣对比,由视觉 AI 评分(1-10)并给出改进建议。应在套图完成后调用。", - "parameters": { - "type": "object", - "properties": {}, - "required": [] - } - } + "parameters": {"type": "object", "properties": {}, "required": []}, + }, }, # ==================== PS 通用操作工具 ==================== - {"type": "function", "function": { - "name": "move_layer", - "description": "移动图层。dx 正值向右,dy 正值向下(单位像素)", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "图层名称"}, - "dx": {"type": "number", "description": "水平位移(像素,正=右)"}, - "dy": {"type": "number", "description": "垂直位移(像素,正=下)"} - }, "required": ["name", "dx", "dy"]} - }}, - {"type": "function", "function": { - "name": "resize_layer", - "description": "缩放图层(百分比)。100=不变,200=放大2倍,50=缩小一半", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "图层名称"}, - "scale_x": {"type": "number", "description": "水平缩放百分比"}, - "scale_y": {"type": "number", "description": "垂直缩放百分比(省略则与 scale_x 相同)"} - }, "required": ["name", "scale_x"]} - }}, - {"type": "function", "function": { - "name": "rotate_layer", - "description": "旋转图层(角度,正值=顺时针)", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "图层名称"}, - "angle": {"type": "number", "description": "旋转角度(正=顺时针)"} - }, "required": ["name", "angle"]} - }}, - {"type": "function", "function": { - "name": "flip_layer", - "description": "翻转图层", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "图层名称"}, - "direction": {"type": "string", "enum": ["horizontal", "vertical"], "description": "翻转方向"} - }, "required": ["name", "direction"]} - }}, - {"type": "function", "function": { - "name": "set_layer_opacity", - "description": "设置图层透明度(0=完全透明,100=完全不透明)", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "图层名称"}, - "opacity": {"type": "number", "description": "透明度 0-100"} - }, "required": ["name", "opacity"]} - }}, - {"type": "function", "function": { - "name": "set_layer_blend_mode", - "description": "设置图层混合模式", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "图层名称"}, - "mode": {"type": "string", "enum": ["normal","multiply","screen","overlay","darken","lighten","color_dodge","color_burn","soft_light","hard_light","difference","exclusion","hue","saturation","color","luminosity"], "description": "混合模式"} - }, "required": ["name", "mode"]} - }}, - {"type": "function", "function": { - "name": "set_layer_visible", - "description": "显示或隐藏图层", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "图层名称"}, - "visible": {"type": "boolean", "description": "true=显示,false=隐藏"} - }, "required": ["name", "visible"]} - }}, - {"type": "function", "function": { - "name": "set_text_content", - "description": "修改文字图层的文本内容", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "文字图层名称"}, - "text": {"type": "string", "description": "新的文本内容"} - }, "required": ["name", "text"]} - }}, - {"type": "function", "function": { - "name": "set_text_color", - "description": "修改文字图层的颜色", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "文字图层名称"}, - "color": {"type": "string", "description": "颜色 hex 值,如 #FF0000"} - }, "required": ["name", "color"]} - }}, - {"type": "function", "function": { - "name": "set_text_size", - "description": "修改文字图层的字号(像素)", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "文字图层名称"}, - "size": {"type": "number", "description": "字号(像素)"} - }, "required": ["name", "size"]} - }}, - {"type": "function", "function": { - "name": "group_layers", - "description": "将多个图层编组", - "parameters": {"type": "object", "properties": { - "layer_names": {"type": "array", "items": {"type": "string"}, "description": "要编组的图层名称列表"}, - "group_name": {"type": "string", "description": "组名称"} - }, "required": ["layer_names", "group_name"]} - }}, - {"type": "function", "function": { - "name": "merge_visible", - "description": "合并所有可见图层", - "parameters": {"type": "object", "properties": {}, "required": []} - }}, - {"type": "function", "function": { - "name": "flatten_image", - "description": "拼合图像(所有图层合并为背景层)", - "parameters": {"type": "object", "properties": {}, "required": []} - }}, - {"type": "function", "function": { - "name": "resize_canvas", - "description": "调整画布大小(像素)", - "parameters": {"type": "object", "properties": { - "width": {"type": "number", "description": "画布宽度(像素)"}, - "height": {"type": "number", "description": "画布高度(像素)"}, - "anchor": {"type": "string", "enum": ["top-left","top","top-right","left","center","right","bottom-left","bottom","bottom-right"], "description": "锚点位置(默认 center)"} - }, "required": ["width", "height"]} - }}, - {"type": "function", "function": { - "name": "adjust_brightness_contrast", - "description": "调整当前图层的亮度和对比度", - "parameters": {"type": "object", "properties": { - "brightness": {"type": "number", "description": "亮度 -150 到 150"}, - "contrast": {"type": "number", "description": "对比度 -50 到 100"} - }, "required": ["brightness", "contrast"]} - }}, - {"type": "function", "function": { - "name": "desaturate", - "description": "将当前图层去色(变为灰度)", - "parameters": {"type": "object", "properties": {}, "required": []} - }}, - {"type": "function", "function": { - "name": "rasterize_layer", - "description": "栅格化图层(将智能对象/文字/形状转为像素图层)", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "图层名称"} - }, "required": ["name"]} - }}, - {"type": "function", "function": { - "name": "align_layers_tool", - "description": "对齐多个图层", - "parameters": {"type": "object", "properties": { - "layer_names": {"type": "array", "items": {"type": "string"}, "description": "要对齐的图层名"}, - "alignment": {"type": "string", "enum": ["left","center_h","right","top","center_v","bottom"], "description": "对齐方式"} - }, "required": ["layer_names", "alignment"]} - }}, - {"type": "function", "function": { - "name": "distribute_layers", - "description": "等距分布多个图层", - "parameters": {"type": "object", "properties": { - "layer_names": {"type": "array", "items": {"type": "string"}, "description": "要分布的图层名"}, - "direction": {"type": "string", "enum": ["horizontal","vertical"], "description": "分布方向"} - }, "required": ["layer_names", "direction"]} - }}, - {"type": "function", "function": { - "name": "add_stroke", - "description": "给图层添加描边效果", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "图层名称"}, - "size": {"type": "number", "description": "描边宽度(像素)"}, - "color": {"type": "string", "description": "描边颜色 hex,如 #FF0000"}, - "position": {"type": "string", "enum": ["outside","inside","center"], "description": "描边位置(默认 outside)"} - }, "required": ["name", "size", "color"]} - }}, - {"type": "function", "function": { - "name": "create_clipping_mask", - "description": "为图层创建剪贴蒙版(裁切到下方图层形状)", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "图层名称"} - }, "required": ["name"]} - }}, - {"type": "function", "function": { - "name": "release_clipping_mask", - "description": "释放图层的剪贴蒙版", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "图层名称"} - }, "required": ["name"]} - }}, - {"type": "function", "function": { - "name": "undo", - "description": "撤销操作(Ctrl+Z),可指定步数", - "parameters": {"type": "object", "properties": { - "steps": {"type": "number", "description": "撤销步数(默认 1)"} - }, "required": []} - }}, - {"type": "function", "function": { - "name": "get_layer_bounds", - "description": "获取图层的位置和尺寸信息(left/top/width/height/center)", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "图层名称"} - }, "required": ["name"]} - }}, - {"type": "function", "function": { - "name": "create_document", - "description": "新建 Photoshop 文档", - "parameters": {"type": "object", "properties": { - "width": {"type": "number", "description": "宽度(像素)"}, - "height": {"type": "number", "description": "高度(像素)"}, - "resolution": {"type": "number", "description": "分辨率 DPI(默认 150)"}, - "name": {"type": "string", "description": "文档名称"} - }, "required": ["width", "height"]} - }}, - {"type": "function", "function": { - "name": "save_document", - "description": "保存当前文档(Ctrl+S)", - "parameters": {"type": "object", "properties": {}, "required": []} - }}, - {"type": "function", "function": { - "name": "resize_image", - "description": "调整图像大小(像素/分辨率)", - "parameters": {"type": "object", "properties": { - "width": {"type": "number", "description": "新宽度(像素)"}, - "height": {"type": "number", "description": "新高度(像素)"}, - "resolution": {"type": "number", "description": "新分辨率 DPI(可选)"} - }, "required": ["width", "height"]} - }}, - {"type": "function", "function": { - "name": "gaussian_blur", - "description": "对当前图层应用高斯模糊", - "parameters": {"type": "object", "properties": { - "radius": {"type": "number", "description": "模糊半径(像素)"} - }, "required": ["radius"]} - }}, - {"type": "function", "function": { - "name": "auto_levels", - "description": "自动色阶(快速修正色调范围)", - "parameters": {"type": "object", "properties": {}, "required": []} - }}, - {"type": "function", "function": { - "name": "auto_contrast", - "description": "自动对比度", - "parameters": {"type": "object", "properties": {}, "required": []} - }}, - {"type": "function", "function": { - "name": "invert_colors", - "description": "反相(颜色取反)", - "parameters": {"type": "object", "properties": {}, "required": []} - }}, - {"type": "function", "function": { - "name": "convert_to_rgb", - "description": "转换为 RGB 色彩模式", - "parameters": {"type": "object", "properties": {}, "required": []} - }}, - {"type": "function", "function": { - "name": "convert_to_cmyk", - "description": "转换为 CMYK 色彩模式(印刷用)", - "parameters": {"type": "object", "properties": {}, "required": []} - }}, - {"type": "function", "function": { - "name": "add_drop_shadow", - "description": "给图层添加投影效果", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "图层名称"}, - "distance": {"type": "number", "description": "投影距离(像素)"}, - "size": {"type": "number", "description": "投影大小/模糊(像素)"}, - "opacity": {"type": "number", "description": "投影不透明度 0-100(默认 75)"}, - "angle": {"type": "number", "description": "光照角度(默认 120)"} - }, "required": ["name", "distance", "size"]} - }}, - {"type": "function", "function": { - "name": "clear_layer_effects", - "description": "清除图层的所有图层样式(投影/描边/发光等)", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "图层名称"} - }, "required": ["name"]} - }}, - {"type": "function", "function": { - "name": "bring_to_front", - "description": "将图层移到最顶层", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "图层名称"} - }, "required": ["name"]} - }}, - {"type": "function", "function": { - "name": "send_to_back", - "description": "将图层移到最底层", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "图层名称"} - }, "required": ["name"]} - }}, - {"type": "function", "function": { - "name": "crop_document", - "description": "裁切画布到指定区域(像素坐标)", - "parameters": {"type": "object", "properties": { - "left": {"type": "number"}, "top": {"type": "number"}, - "right": {"type": "number"}, "bottom": {"type": "number"} - }, "required": ["left", "top", "right", "bottom"]} - }}, - {"type": "function", "function": { - "name": "trim_document", - "description": "自动裁切(去除透明或纯色边缘)", - "parameters": {"type": "object", "properties": { - "type": {"type": "string", "enum": ["transparent", "topleft"], "description": "裁切依据(默认 topleft 即左上角颜色)"} - }, "required": []} - }}, - {"type": "function", "function": { - "name": "add_guide", - "description": "添加参考线", - "parameters": {"type": "object", "properties": { - "position": {"type": "number", "description": "位置(像素)"}, - "direction": {"type": "string", "enum": ["horizontal", "vertical"], "description": "方向"} - }, "required": ["position", "direction"]} - }}, - {"type": "function", "function": { - "name": "clear_guides", - "description": "清除所有参考线", - "parameters": {"type": "object", "properties": {}, "required": []} - }}, - {"type": "function", "function": { - "name": "fill_selection", - "description": "用颜色填充当前选区", - "parameters": {"type": "object", "properties": { - "color": {"type": "string", "description": "填充颜色 hex"}, - "opacity": {"type": "number", "description": "填充不透明度 0-100(默认 100)"} - }, "required": ["color"]} - }}, - {"type": "function", "function": { - "name": "inverse_selection", - "description": "反选(选中未选区域)", - "parameters": {"type": "object", "properties": {}, "required": []} - }}, - {"type": "function", "function": { - "name": "feather_selection", - "description": "羽化选区边缘", - "parameters": {"type": "object", "properties": { - "radius": {"type": "number", "description": "羽化半径(像素)"} - }, "required": ["radius"]} - }}, - {"type": "function", "function": { - "name": "adjust_levels", - "description": "调整色阶(输入黑点/灰度系数/白点)", - "parameters": {"type": "object", "properties": { - "black": {"type": "number", "description": "输入黑点 0-253(默认 0)"}, - "gamma": {"type": "number", "description": "灰度系数 0.1-9.99(默认 1.0,<1变暗 >1变亮)"}, - "white": {"type": "number", "description": "输入白点 2-255(默认 255)"} - }, "required": ["black", "gamma", "white"]} - }}, - {"type": "function", "function": { - "name": "copy_merged_to_new_layer", - "description": "复制合并所有可见内容到新图层(Ctrl+Shift+C + 粘贴)", - "parameters": {"type": "object", "properties": {}, "required": []} - }}, - {"type": "function", "function": { - "name": "lock_layer", - "description": "锁定或解锁图层", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "图层名称"}, - "locked": {"type": "boolean", "description": "true=锁定,false=解锁"} - }, "required": ["name", "locked"]} - }}, - {"type": "function", "function": { - "name": "set_foreground_color", - "description": "设置前景色", - "parameters": {"type": "object", "properties": { - "color": {"type": "string", "description": "颜色 hex 值"} - }, "required": ["color"]} - }}, - {"type": "function", "function": { - "name": "save_document_as", - "description": "文件另存为(支持 PSD/PNG/JPG)", - "parameters": {"type": "object", "properties": { - "path": {"type": "string", "description": "保存路径"}, - "format": {"type": "string", "enum": ["psd", "png", "jpg"], "description": "文件格式"} - }, "required": ["path"]} - }}, - {"type": "function", "function": { - "name": "add_layer_mask", - "description": "为图层添加蒙版(全白=全显示 / 全黑=全隐藏 / 基于当前选区)", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "图层名称"}, - "hide_all": {"type": "boolean", "description": "true=全黑蒙版(隐藏),false=全白蒙版(显示,默认)"} - }, "required": ["name"]} - }}, - {"type": "function", "function": { - "name": "delete_layer_mask", - "description": "删除图层蒙版", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "图层名称"}, - "apply": {"type": "boolean", "description": "true=应用蒙版后删除,false=直接丢弃(默认)"} - }, "required": ["name"]} - }}, - {"type": "function", "function": { - "name": "convert_to_smart_object", - "description": "将图层转为智能对象", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "图层名称"} - }, "required": ["name"]} - }}, - {"type": "function", "function": { - "name": "adjust_hsl", - "description": "调整色相/饱和度/明度(H=-180~180, S=-100~100, L=-100~100)", - "parameters": {"type": "object", "properties": { - "hue": {"type": "number", "description": "色相偏移 -180到180"}, - "saturation": {"type": "number", "description": "饱和度 -100到100"}, - "lightness": {"type": "number", "description": "明度 -100到100"} - }, "required": ["hue", "saturation", "lightness"]} - }}, - {"type": "function", "function": { - "name": "create_text_layer", - "description": "创建文字图层", - "parameters": {"type": "object", "properties": { - "text": {"type": "string", "description": "文字内容"}, - "x": {"type": "number", "description": "X 坐标(像素)"}, - "y": {"type": "number", "description": "Y 坐标(像素)"}, - "font_size": {"type": "number", "description": "字号(像素)"}, - "color": {"type": "string", "description": "颜色 hex(默认黑色)"}, - "font_name": {"type": "string", "description": "字体名称(可选)"} - }, "required": ["text", "x", "y", "font_size"]} - }}, - {"type": "function", "function": { - "name": "get_active_layer_info", - "description": "获取当前选中图层的详细信息(名称/类型/尺寸/位置/透明度/混合模式等)", - "parameters": {"type": "object", "properties": {}, "required": []} - }}, - {"type": "function", "function": { - "name": "list_all_layer_names", - "description": "列出文档中所有图层名称(扁平列表,含类型和可见性)", - "parameters": {"type": "object", "properties": {}, "required": []} - }}, - {"type": "function", "function": { - "name": "duplicate_layer_to_document", - "description": "复制图层到另一个已打开的文档", - "parameters": {"type": "object", "properties": { - "name": {"type": "string", "description": "图层名称"}, - "target_doc": {"type": "string", "description": "目标文档名称"} - }, "required": ["name", "target_doc"]} - }}, - {"type": "function", "function": { - "name": "switch_document", - "description": "切换到另一个已打开的文档", - "parameters": {"type": "object", "properties": { - "doc_name": {"type": "string", "description": "文档名称"} - }, "required": ["doc_name"]} - }}, - {"type": "function", "function": { - "name": "list_documents", - "description": "列出所有已打开的文档", - "parameters": {"type": "object", "properties": {}, "required": []} - }}, - {"type": "function", "function": { - "name": "get_pixel_color", - "description": "获取画布上指定坐标的像素颜色(取色器)", - "parameters": {"type": "object", "properties": { - "x": {"type": "number", "description": "X 坐标"}, - "y": {"type": "number", "description": "Y 坐标"} - }, "required": ["x", "y"]} - }}, - {"type": "function", "function": { - "name": "trim_document", - "description": "自动裁切(去除透明或纯色边缘)", - "parameters": {"type": "object", "properties": { - "type": {"type": "string", "enum": ["transparent","topleft"], "description": "裁切依据"} - }, "required": []} - }}, - {"type": "function", "function": { - "name": "close_document", - "description": "关闭当前文档", - "parameters": {"type": "object", "properties": { - "save": {"type": "boolean", "description": "是否保存(默认不保存)"} - }, "required": []} - }}, + { + "type": "function", + "function": { + "name": "move_layer", + "description": "移动图层。dx 正值向右,dy 正值向下(单位像素)", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "图层名称"}, + "dx": {"type": "number", "description": "水平位移(像素,正=右)"}, + "dy": {"type": "number", "description": "垂直位移(像素,正=下)"}, + }, + "required": ["name", "dx", "dy"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "resize_layer", + "description": "缩放图层(百分比)。100=不变,200=放大2倍,50=缩小一半", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "图层名称"}, + "scale_x": {"type": "number", "description": "水平缩放百分比"}, + "scale_y": { + "type": "number", + "description": "垂直缩放百分比(省略则与 scale_x 相同)", + }, + }, + "required": ["name", "scale_x"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "rotate_layer", + "description": "旋转图层(角度,正值=顺时针)", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "图层名称"}, + "angle": {"type": "number", "description": "旋转角度(正=顺时针)"}, + }, + "required": ["name", "angle"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "flip_layer", + "description": "翻转图层", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "图层名称"}, + "direction": { + "type": "string", + "enum": ["horizontal", "vertical"], + "description": "翻转方向", + }, + }, + "required": ["name", "direction"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "set_layer_opacity", + "description": "设置图层透明度(0=完全透明,100=完全不透明)", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "图层名称"}, + "opacity": {"type": "number", "description": "透明度 0-100"}, + }, + "required": ["name", "opacity"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "set_layer_blend_mode", + "description": "设置图层混合模式", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "图层名称"}, + "mode": { + "type": "string", + "enum": [ + "normal", + "multiply", + "screen", + "overlay", + "darken", + "lighten", + "color_dodge", + "color_burn", + "soft_light", + "hard_light", + "difference", + "exclusion", + "hue", + "saturation", + "color", + "luminosity", + ], + "description": "混合模式", + }, + }, + "required": ["name", "mode"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "set_layer_visible", + "description": "显示或隐藏图层", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "图层名称"}, + "visible": { + "type": "boolean", + "description": "true=显示,false=隐藏", + }, + }, + "required": ["name", "visible"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "set_text_content", + "description": "修改文字图层的文本内容", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "文字图层名称"}, + "text": {"type": "string", "description": "新的文本内容"}, + }, + "required": ["name", "text"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "set_text_color", + "description": "修改文字图层的颜色", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "文字图层名称"}, + "color": { + "type": "string", + "description": "颜色 hex 值,如 #FF0000", + }, + }, + "required": ["name", "color"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "set_text_size", + "description": "修改文字图层的字号(像素)", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "文字图层名称"}, + "size": {"type": "number", "description": "字号(像素)"}, + }, + "required": ["name", "size"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "group_layers", + "description": "将多个图层编组", + "parameters": { + "type": "object", + "properties": { + "layer_names": { + "type": "array", + "items": {"type": "string"}, + "description": "要编组的图层名称列表", + }, + "group_name": {"type": "string", "description": "组名称"}, + }, + "required": ["layer_names", "group_name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "merge_visible", + "description": "合并所有可见图层", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "flatten_image", + "description": "拼合图像(所有图层合并为背景层)", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "resize_canvas", + "description": "调整画布大小(像素)", + "parameters": { + "type": "object", + "properties": { + "width": {"type": "number", "description": "画布宽度(像素)"}, + "height": {"type": "number", "description": "画布高度(像素)"}, + "anchor": { + "type": "string", + "enum": [ + "top-left", + "top", + "top-right", + "left", + "center", + "right", + "bottom-left", + "bottom", + "bottom-right", + ], + "description": "锚点位置(默认 center)", + }, + }, + "required": ["width", "height"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "adjust_brightness_contrast", + "description": "调整当前图层的亮度和对比度", + "parameters": { + "type": "object", + "properties": { + "brightness": {"type": "number", "description": "亮度 -150 到 150"}, + "contrast": {"type": "number", "description": "对比度 -50 到 100"}, + }, + "required": ["brightness", "contrast"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "desaturate", + "description": "将当前图层去色(变为灰度)", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "rasterize_layer", + "description": "栅格化图层(将智能对象/文字/形状转为像素图层)", + "parameters": { + "type": "object", + "properties": {"name": {"type": "string", "description": "图层名称"}}, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "align_layers_tool", + "description": "对齐多个图层", + "parameters": { + "type": "object", + "properties": { + "layer_names": { + "type": "array", + "items": {"type": "string"}, + "description": "要对齐的图层名", + }, + "alignment": { + "type": "string", + "enum": [ + "left", + "center_h", + "right", + "top", + "center_v", + "bottom", + ], + "description": "对齐方式", + }, + }, + "required": ["layer_names", "alignment"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "distribute_layers", + "description": "等距分布多个图层", + "parameters": { + "type": "object", + "properties": { + "layer_names": { + "type": "array", + "items": {"type": "string"}, + "description": "要分布的图层名", + }, + "direction": { + "type": "string", + "enum": ["horizontal", "vertical"], + "description": "分布方向", + }, + }, + "required": ["layer_names", "direction"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "add_stroke", + "description": "给图层添加描边效果", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "图层名称"}, + "size": {"type": "number", "description": "描边宽度(像素)"}, + "color": { + "type": "string", + "description": "描边颜色 hex,如 #FF0000", + }, + "position": { + "type": "string", + "enum": ["outside", "inside", "center"], + "description": "描边位置(默认 outside)", + }, + }, + "required": ["name", "size", "color"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "create_clipping_mask", + "description": "为图层创建剪贴蒙版(裁切到下方图层形状)", + "parameters": { + "type": "object", + "properties": {"name": {"type": "string", "description": "图层名称"}}, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "release_clipping_mask", + "description": "释放图层的剪贴蒙版", + "parameters": { + "type": "object", + "properties": {"name": {"type": "string", "description": "图层名称"}}, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "undo", + "description": "撤销操作(Ctrl+Z),可指定步数", + "parameters": { + "type": "object", + "properties": { + "steps": {"type": "number", "description": "撤销步数(默认 1)"} + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_layer_bounds", + "description": "获取图层的位置和尺寸信息(left/top/width/height/center)", + "parameters": { + "type": "object", + "properties": {"name": {"type": "string", "description": "图层名称"}}, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "create_document", + "description": "新建 Photoshop 文档", + "parameters": { + "type": "object", + "properties": { + "width": {"type": "number", "description": "宽度(像素)"}, + "height": {"type": "number", "description": "高度(像素)"}, + "resolution": { + "type": "number", + "description": "分辨率 DPI(默认 150)", + }, + "name": {"type": "string", "description": "文档名称"}, + }, + "required": ["width", "height"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "save_document", + "description": "保存当前文档(Ctrl+S)", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "resize_image", + "description": "调整图像大小(像素/分辨率)", + "parameters": { + "type": "object", + "properties": { + "width": {"type": "number", "description": "新宽度(像素)"}, + "height": {"type": "number", "description": "新高度(像素)"}, + "resolution": { + "type": "number", + "description": "新分辨率 DPI(可选)", + }, + }, + "required": ["width", "height"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "gaussian_blur", + "description": "对当前图层应用高斯模糊", + "parameters": { + "type": "object", + "properties": { + "radius": {"type": "number", "description": "模糊半径(像素)"} + }, + "required": ["radius"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "auto_levels", + "description": "自动色阶(快速修正色调范围)", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "auto_contrast", + "description": "自动对比度", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "invert_colors", + "description": "反相(颜色取反)", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "convert_to_rgb", + "description": "转换为 RGB 色彩模式", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "convert_to_cmyk", + "description": "转换为 CMYK 色彩模式(印刷用)", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "add_drop_shadow", + "description": "给图层添加投影效果", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "图层名称"}, + "distance": {"type": "number", "description": "投影距离(像素)"}, + "size": {"type": "number", "description": "投影大小/模糊(像素)"}, + "opacity": { + "type": "number", + "description": "投影不透明度 0-100(默认 75)", + }, + "angle": {"type": "number", "description": "光照角度(默认 120)"}, + }, + "required": ["name", "distance", "size"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "clear_layer_effects", + "description": "清除图层的所有图层样式(投影/描边/发光等)", + "parameters": { + "type": "object", + "properties": {"name": {"type": "string", "description": "图层名称"}}, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "bring_to_front", + "description": "将图层移到最顶层", + "parameters": { + "type": "object", + "properties": {"name": {"type": "string", "description": "图层名称"}}, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "send_to_back", + "description": "将图层移到最底层", + "parameters": { + "type": "object", + "properties": {"name": {"type": "string", "description": "图层名称"}}, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "crop_document", + "description": "裁切画布到指定区域(像素坐标)", + "parameters": { + "type": "object", + "properties": { + "left": {"type": "number"}, + "top": {"type": "number"}, + "right": {"type": "number"}, + "bottom": {"type": "number"}, + }, + "required": ["left", "top", "right", "bottom"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "trim_document", + "description": "自动裁切(去除透明或纯色边缘)", + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["transparent", "topleft"], + "description": "裁切依据(默认 topleft 即左上角颜色)", + } + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "add_guide", + "description": "添加参考线", + "parameters": { + "type": "object", + "properties": { + "position": {"type": "number", "description": "位置(像素)"}, + "direction": { + "type": "string", + "enum": ["horizontal", "vertical"], + "description": "方向", + }, + }, + "required": ["position", "direction"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "clear_guides", + "description": "清除所有参考线", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "fill_selection", + "description": "用颜色填充当前选区", + "parameters": { + "type": "object", + "properties": { + "color": {"type": "string", "description": "填充颜色 hex"}, + "opacity": { + "type": "number", + "description": "填充不透明度 0-100(默认 100)", + }, + }, + "required": ["color"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "inverse_selection", + "description": "反选(选中未选区域)", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "feather_selection", + "description": "羽化选区边缘", + "parameters": { + "type": "object", + "properties": { + "radius": {"type": "number", "description": "羽化半径(像素)"} + }, + "required": ["radius"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "adjust_levels", + "description": "调整色阶(输入黑点/灰度系数/白点)", + "parameters": { + "type": "object", + "properties": { + "black": { + "type": "number", + "description": "输入黑点 0-253(默认 0)", + }, + "gamma": { + "type": "number", + "description": "灰度系数 0.1-9.99(默认 1.0,<1变暗 >1变亮)", + }, + "white": { + "type": "number", + "description": "输入白点 2-255(默认 255)", + }, + }, + "required": ["black", "gamma", "white"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "copy_merged_to_new_layer", + "description": "复制合并所有可见内容到新图层(Ctrl+Shift+C + 粘贴)", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "lock_layer", + "description": "锁定或解锁图层", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "图层名称"}, + "locked": { + "type": "boolean", + "description": "true=锁定,false=解锁", + }, + }, + "required": ["name", "locked"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "set_foreground_color", + "description": "设置前景色", + "parameters": { + "type": "object", + "properties": { + "color": {"type": "string", "description": "颜色 hex 值"} + }, + "required": ["color"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "save_document_as", + "description": "文件另存为(支持 PSD/PNG/JPG)", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "保存路径"}, + "format": { + "type": "string", + "enum": ["psd", "png", "jpg"], + "description": "文件格式", + }, + }, + "required": ["path"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "add_layer_mask", + "description": "为图层添加蒙版(全白=全显示 / 全黑=全隐藏 / 基于当前选区)", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "图层名称"}, + "hide_all": { + "type": "boolean", + "description": "true=全黑蒙版(隐藏),false=全白蒙版(显示,默认)", + }, + }, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "delete_layer_mask", + "description": "删除图层蒙版", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "图层名称"}, + "apply": { + "type": "boolean", + "description": "true=应用蒙版后删除,false=直接丢弃(默认)", + }, + }, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "convert_to_smart_object", + "description": "将图层转为智能对象", + "parameters": { + "type": "object", + "properties": {"name": {"type": "string", "description": "图层名称"}}, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "adjust_hsl", + "description": "调整色相/饱和度/明度(H=-180~180, S=-100~100, L=-100~100)", + "parameters": { + "type": "object", + "properties": { + "hue": {"type": "number", "description": "色相偏移 -180到180"}, + "saturation": {"type": "number", "description": "饱和度 -100到100"}, + "lightness": {"type": "number", "description": "明度 -100到100"}, + }, + "required": ["hue", "saturation", "lightness"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "create_text_layer", + "description": "创建文字图层", + "parameters": { + "type": "object", + "properties": { + "text": {"type": "string", "description": "文字内容"}, + "x": {"type": "number", "description": "X 坐标(像素)"}, + "y": {"type": "number", "description": "Y 坐标(像素)"}, + "font_size": {"type": "number", "description": "字号(像素)"}, + "color": {"type": "string", "description": "颜色 hex(默认黑色)"}, + "font_name": {"type": "string", "description": "字体名称(可选)"}, + }, + "required": ["text", "x", "y", "font_size"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_active_layer_info", + "description": "获取当前选中图层的详细信息(名称/类型/尺寸/位置/透明度/混合模式等)", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "list_all_layer_names", + "description": "列出文档中所有图层名称(扁平列表,含类型和可见性)", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "duplicate_layer_to_document", + "description": "复制图层到另一个已打开的文档", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "图层名称"}, + "target_doc": {"type": "string", "description": "目标文档名称"}, + }, + "required": ["name", "target_doc"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "switch_document", + "description": "切换到另一个已打开的文档", + "parameters": { + "type": "object", + "properties": { + "doc_name": {"type": "string", "description": "文档名称"} + }, + "required": ["doc_name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "list_documents", + "description": "列出所有已打开的文档", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "get_pixel_color", + "description": "获取画布上指定坐标的像素颜色(取色器)", + "parameters": { + "type": "object", + "properties": { + "x": {"type": "number", "description": "X 坐标"}, + "y": {"type": "number", "description": "Y 坐标"}, + }, + "required": ["x", "y"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "close_document", + "description": "关闭当前文档", + "parameters": { + "type": "object", + "properties": { + "save": {"type": "boolean", "description": "是否保存(默认不保存)"} + }, + "required": [], + }, + }, + }, ] # 工具名称映射(中文显示用) @@ -687,6 +1164,7 @@ TOOL_DISPLAY_NAMES = { "move_layer_to_group": "移入图层组", "identify_pieces": "识别裁片部位", "generate_garment_preview": "生成花样预览", + "generate_design_images": "豆包出图", "extract_and_apply_all_pieces": "提取并套图", "verify_pattern_result": "验证套图效果", # PS 通用操作 diff --git a/Server/app/api/v1/logs.py b/Server/app/api/v1/logs.py index a6912d8..b18a33a 100644 --- a/Server/app/api/v1/logs.py +++ b/Server/app/api/v1/logs.py @@ -5,18 +5,65 @@ from typing import List, Optional from datetime import datetime, timedelta +from urllib.parse import urlparse from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy.orm import Session, joinedload from sqlalchemy import desc from app.db import get_db +from app.core.config import settings from app.core.security import get_current_user from app.models.logs import PltProcessRecord, UserActionLog, PltPiece from app.models.user import User router = APIRouter() +CURRENT_QINIU_DOMAIN = settings.QINIU_DOMAIN.rstrip("/") +CURRENT_QINIU_HOST = urlparse(CURRENT_QINIU_DOMAIN).netloc.lower() if CURRENT_QINIU_DOMAIN else "" +KNOWN_QINIU_HOST_MARKERS = ("qiniuio.com", "clouddn.com", "qnssl.com") +LEGACY_QINIU_HOSTS = { + "iovip-z2.qiniuio.com", + "t9zfnhhrn.hn-bkt.clouddn.com", +} + + +def normalize_qiniu_url(url: Optional[str]) -> Optional[str]: + """将历史记录中的旧七牛域名切换到当前绑定域名。""" + if not url or not CURRENT_QINIU_DOMAIN: + return url + + parsed = urlparse(url) + host = parsed.netloc.lower() + + if not parsed.scheme or not host or not parsed.path: + return url + + if host == CURRENT_QINIU_HOST: + return url + + is_qiniu_host = host in LEGACY_QINIU_HOSTS or any( + host.endswith(marker) for marker in KNOWN_QINIU_HOST_MARKERS + ) + if not is_qiniu_host: + return url + + suffix = parsed.path + if parsed.query: + suffix = f"{suffix}?{parsed.query}" + return f"{CURRENT_QINIU_DOMAIN}{suffix}" + + +def normalize_record_piece_urls(record: Optional[PltProcessRecord]) -> Optional[PltProcessRecord]: + """在返回详情前修正裁片图片地址,兼容旧域名历史记录。""" + if not record or not record.pieces: + return record + + for piece in record.pieces: + piece.image_url = normalize_qiniu_url(piece.image_url) + + return record + # ==================== 数据模型 ==================== @@ -197,7 +244,7 @@ async def create_plt_record( record_id=record.id, group_id=piece_data.group_id, size=piece_data.size, - image_url=piece_data.image_url, + image_url=normalize_qiniu_url(piece_data.image_url), width_px=piece_data.width_px, height_px=piece_data.height_px, width_cm=piece_data.width_cm, @@ -235,28 +282,29 @@ async def get_plt_record_detail( if not record: raise HTTPException(status_code=404, detail="记录不存在") - return record + return normalize_record_piece_urls(record) @router.get("/plt/history", response_model=List[PltRecordResponse]) async def get_plt_history( limit: int = Query(20, ge=1, le=100), - days: int = Query(7, ge=1, le=30), # 保留天数,默认7天 + days: int = Query(0, ge=0, le=3650), # 0 表示不限制时间 current_username: str = Depends(get_current_user), db: Session = Depends(get_db) ): - """获取用户的PLT处理历史(默认保留7天)""" + """获取用户的 PLT 处理历史,days=0 表示全部记录""" user = db.query(User).filter(User.username == current_username).first() if not user: raise HTTPException(status_code=404, detail="用户不存在") - - # 只返回指定天数内的记录 - since = datetime.now() - timedelta(days=days) - - records = db.query(PltProcessRecord)\ - .filter(PltProcessRecord.user_id == user.id)\ - .filter(PltProcessRecord.created_at >= since)\ - .order_by(desc(PltProcessRecord.created_at))\ + + query = db.query(PltProcessRecord)\ + .filter(PltProcessRecord.user_id == user.id) + + if days > 0: + since = datetime.now() - timedelta(days=days) + query = query.filter(PltProcessRecord.created_at >= since) + + records = query.order_by(desc(PltProcessRecord.created_at))\ .limit(limit)\ .all() diff --git a/Server/app/api/v1/user_profile.py b/Server/app/api/v1/user_profile.py index 9bf25e3..7d8ef98 100644 --- a/Server/app/api/v1/user_profile.py +++ b/Server/app/api/v1/user_profile.py @@ -21,6 +21,25 @@ class UserProfileUpdate(BaseModel): nickname: Optional[str] = None avatar: Optional[str] = None email: Optional[str] = None + ai_provider: Optional[str] = None + ai_base_url: Optional[str] = None + ai_chat_base_url: Optional[str] = None + ai_vision_base_url: Optional[str] = None + ai_image_base_url: Optional[str] = None + ai_api_key: Optional[str] = None + ai_model: Optional[str] = None + ai_vision_model: Optional[str] = None + ai_image_model: Optional[str] = None + clear_ai_api_key: bool = False + + +def mask_api_key(value: Optional[str]) -> Optional[str]: + if not value: + return None + raw = value.strip() + if len(raw) <= 8: + return "*" * len(raw) + return f"{raw[:4]}{'*' * (len(raw) - 8)}{raw[-4:]}" # ==================== 用户资料管理 ==================== @@ -43,6 +62,16 @@ async def get_user_profile(db: Session = Depends(get_db), current_username: str "vip_daily_quota": user.vip_daily_quota, "total_check_in_days": user.total_check_in_days, "consecutive_check_in": user.consecutive_check_in, + "ai_provider": user.ai_provider or "ark", + "ai_base_url": user.ai_base_url or "", + "ai_chat_base_url": user.ai_chat_base_url or "", + "ai_vision_base_url": user.ai_vision_base_url or "", + "ai_image_base_url": user.ai_image_base_url or "", + "ai_model": user.ai_model or "", + "ai_vision_model": user.ai_vision_model or "", + "ai_image_model": user.ai_image_model or "", + "has_ai_api_key": bool(user.ai_api_key), + "ai_api_key_masked": mask_api_key(user.ai_api_key), "created_at": user.created_at.isoformat() if user.created_at else None } @@ -64,6 +93,28 @@ async def update_user_profile(data: UserProfileUpdate, db: Session = Depends(get user.avatar = data.avatar if data.email is not None: user.email = data.email + if data.ai_provider is not None: + user.ai_provider = data.ai_provider.strip().lower() or None + if data.ai_base_url is not None: + user.ai_base_url = data.ai_base_url.strip() or None + if data.ai_chat_base_url is not None: + user.ai_chat_base_url = data.ai_chat_base_url.strip() or None + if data.ai_vision_base_url is not None: + user.ai_vision_base_url = data.ai_vision_base_url.strip() or None + if data.ai_image_base_url is not None: + user.ai_image_base_url = data.ai_image_base_url.strip() or None + if data.ai_model is not None: + user.ai_model = data.ai_model.strip() or None + if data.ai_vision_model is not None: + user.ai_vision_model = data.ai_vision_model.strip() or None + if data.ai_image_model is not None: + user.ai_image_model = data.ai_image_model.strip() or None + if data.clear_ai_api_key: + user.ai_api_key = None + elif data.ai_api_key is not None: + trimmed_key = data.ai_api_key.strip() + if trimmed_key: + user.ai_api_key = trimmed_key db.commit() diff --git a/Server/app/core/config.py b/Server/app/core/config.py index 08f1dff..7285479 100644 --- a/Server/app/core/config.py +++ b/Server/app/core/config.py @@ -1,5 +1,30 @@ +from pathlib import Path from pydantic_settings import BaseSettings, SettingsConfigDict +BASE_DIR = Path(__file__).resolve().parents[2] +ENV_FILE = BASE_DIR / ".env" +PLACEHOLDER_SECRET_VALUES = { + "", + "your-super-secret-key-change-this", + "admin-secret-token-change-this", +} + + +def resolve_database_url(raw_url: str) -> str: + """Resolve relative SQLite paths against the Server directory.""" + if not raw_url.startswith("sqlite:///"): + return raw_url + + sqlite_path = raw_url.removeprefix("sqlite:///") + if not sqlite_path or sqlite_path == ":memory:": + return raw_url + + path_obj = Path(sqlite_path) + if path_obj.is_absolute(): + return raw_url + + return f"sqlite:///{(BASE_DIR / path_obj).resolve().as_posix()}" + class Settings(BaseSettings): ENV: str = "development" PROJECT_NAME: str = "DesignerCEP Backend" @@ -25,17 +50,41 @@ class Settings(BaseSettings): QINIU_BUCKET: str = "" QINIU_DOMAIN: str = "" - # AI 配置 — 通义千问 Qwen(DashScope) - AI_API_KEY: str = "" # LLM API Key(OpenAI / DeepSeek 等) - AI_BASE_URL: str = "" # LLM API 地址(留空则用 OpenAI 默认) - AI_MODEL: str = "qwen3-max-2026-01-23" # 默认模型(通义千问3深度思考) - AI_VISION_MODEL: str = "qwen-vl-max-latest" # 视觉分析模型(看图分析成衣) - AI_IMAGE_EDIT_MODEL: str = "qwen-image-edit-max-2026-01-16" # 图片编辑/生成模型 + # AI 配置 + AI_PROVIDER: str = "ark" + AI_API_KEY: str = "" + AI_BASE_URL: str = "https://ark.cn-beijing.volces.com/api/v3" + AI_CHAT_BASE_URL: str = "" + AI_VISION_BASE_URL: str = "" + AI_IMAGE_BASE_URL: str = "" + AI_MODEL: str = "doubao-seed-2-0-mini-260215" + AI_VISION_MODEL: str = "doubao-seed-2-0-mini-260215" + AI_IMAGE_EDIT_MODEL: str = "doubao-seedream-5-0-260128" + AI_CHAT_MODELS: str = "" + AI_VISION_MODELS: str = "" + AI_IMAGE_EDIT_MODELS: str = "" - # AI 配置 — Gemini(第三方代理) - GEMINI_API_KEY: str = "" - GEMINI_BASE_URL: str = "https://api.apiqik.online" + # 兼容旧字段 + ARK_API_KEY: str = "" + ARK_BASE_URL: str = "https://ark.cn-beijing.volces.com/api/v3" - model_config = SettingsConfigDict(env_file=".env", extra="ignore") + model_config = SettingsConfigDict( + env_file=ENV_FILE, + env_file_encoding="utf-8", + extra="ignore" + ) + + def runtime_warnings(self) -> list[str]: + warnings: list[str] = [] + if not ENV_FILE.exists(): + warnings.append("未找到 Server/.env,当前使用的是代码默认配置。") + if self.SECRET_KEY in PLACEHOLDER_SECRET_VALUES: + warnings.append("SECRET_KEY 仍是默认值,请在部署前替换。") + if self.ADMIN_TOKEN in PLACEHOLDER_SECRET_VALUES: + warnings.append("ADMIN_TOKEN 仍是默认值,请在部署前替换。") + if not self.AI_API_KEY and not self.ARK_API_KEY: + warnings.append("AI_API_KEY / ARK_API_KEY 未配置;如果走用户自带 Key 模式可忽略此项。") + return warnings settings = Settings() +settings.DATABASE_URL = resolve_database_url(settings.DATABASE_URL) diff --git a/Server/app/db.py b/Server/app/db.py index f4544e8..d36ae9f 100644 --- a/Server/app/db.py +++ b/Server/app/db.py @@ -2,8 +2,10 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, declarative_base from app.core.config import settings -# 数据库连接字符串,默认使用 SQLite 本地文件 -SQLALCHEMY_DATABASE_URL = getattr(settings, "DATABASE_URL", "sqlite:///./designercep.db") +# 数据库连接字符串,SQLite 相对路径已在配置层解析为 Server 目录下的绝对路径 +SQLALCHEMY_DATABASE_URL = getattr( + settings, "DATABASE_URL", "sqlite:///./designercep.db" +) # 创建数据库引擎 engine = create_engine( @@ -81,7 +83,8 @@ def seed_data(): db.close() def ensure_migrations(): - # 轻量级迁移:为 SQLite 动态添加缺失列 + # 轻量级迁移:仅作为本地 SQLite 的兜底补列逻辑。 + # 正式环境请优先使用 Alembic(见 Server/migrations/README.md)。 if not SQLALCHEMY_DATABASE_URL.startswith("sqlite"): return with engine.connect() as conn: @@ -152,3 +155,27 @@ def ensure_migrations(): add_col("users", "consecutive_check_in", "INTEGER DEFAULT 0") if not has_column("users", "last_check_in_date"): add_col("users", "last_check_in_date", "DATE NULL") + if not has_column("users", "ai_provider"): + add_col("users", "ai_provider", "VARCHAR(32) NULL") + if not has_column("users", "ai_api_key"): + add_col("users", "ai_api_key", "TEXT NULL") + if not has_column("users", "ai_base_url"): + add_col("users", "ai_base_url", "VARCHAR(255) NULL") + if not has_column("users", "ai_chat_base_url"): + add_col("users", "ai_chat_base_url", "VARCHAR(255) NULL") + if not has_column("users", "ai_vision_base_url"): + add_col("users", "ai_vision_base_url", "VARCHAR(255) NULL") + if not has_column("users", "ai_image_base_url"): + add_col("users", "ai_image_base_url", "VARCHAR(255) NULL") + if not has_column("users", "ai_model"): + add_col("users", "ai_model", "VARCHAR(120) NULL") + if not has_column("users", "ai_vision_model"): + add_col("users", "ai_vision_model", "VARCHAR(120) NULL") + if not has_column("users", "ai_image_model"): + add_col("users", "ai_image_model", "VARCHAR(120) NULL") + + # chat_sessions 会话工作流字段 + if not has_column("chat_sessions", "active_skill_id"): + add_col("chat_sessions", "active_skill_id", "VARCHAR(80) NULL") + if not has_column("chat_sessions", "workflow_state"): + add_col("chat_sessions", "workflow_state", "TEXT NULL") diff --git a/Server/app/main.py b/Server/app/main.py index 4c42904..7fe5848 100644 --- a/Server/app/main.py +++ b/Server/app/main.py @@ -2,18 +2,39 @@ from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles import os +import logging from app.core.config import settings -from app.api.v1 import auth, client, admin, analytics, admin_config, feature, checkin, user_profile, stats, algorithm, logs, ai_chat +from app.api.v1 import ( + auth, + client, + admin, + analytics, + admin_config, + feature, + checkin, + user_profile, + stats, + algorithm, + logs, + ai_chat, + ai_pattern, + ai_identify, +) from app.db import init_db from datetime import datetime +from pathlib import Path app = FastAPI(title=settings.PROJECT_NAME) +log = logging.getLogger("designercep.startup") + +SERVER_DIR = Path(__file__).resolve().parents[2] +ARCHIVES_DIR = SERVER_DIR / "archives" # Ensure archives directory exists -os.makedirs("archives", exist_ok=True) +os.makedirs(ARCHIVES_DIR, exist_ok=True) # ========== CORS 配置 ========== -IS_DEV = os.getenv("ENV", "development") == "development" +IS_DEV = settings.ENV == "development" if IS_DEV: # 开发环境:保持宽松 @@ -27,9 +48,9 @@ if IS_DEV: ) else: # 生产环境:严格配置 - allowed_origins = os.getenv("ALLOWED_ORIGINS", "").split(",") + allowed_origins = settings.ALLOWED_ORIGINS.split(",") print(f"🔒 Running in Production Mode: CORS allowed origins: {allowed_origins}") - + app.add_middleware( CORSMiddleware, allow_origins=allowed_origins, @@ -37,63 +58,93 @@ else: allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_headers=["*"], ) - + # ✅ CEP 环境特殊处理 (Origin: null or cep://) @app.middleware("http") async def cep_cors_middleware(request: Request, call_next): origin = request.headers.get("origin") - + # CEP 的 Origin 是 null 或 cep:// if origin in ["null", None] or (origin and origin.startswith("cep://")): response = await call_next(request) response.headers["Access-Control-Allow-Origin"] = "*" response.headers["Access-Control-Allow-Credentials"] = "true" - response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" + response.headers["Access-Control-Allow-Methods"] = ( + "GET, POST, PUT, DELETE, OPTIONS" + ) response.headers["Access-Control-Allow-Headers"] = "*" return response - + return await call_next(request) -app.include_router(auth.router, prefix=f"{settings.API_V1_STR}/auth", tags=["authentication"]) -app.include_router(client.router, prefix=f"{settings.API_V1_STR}/client", tags=["client"]) + +app.include_router( + auth.router, prefix=f"{settings.API_V1_STR}/auth", tags=["authentication"] +) +app.include_router( + client.router, prefix=f"{settings.API_V1_STR}/client", tags=["client"] +) app.include_router(admin.router, prefix=f"{settings.API_V1_STR}/admin", tags=["admin"]) -app.include_router(analytics.router, prefix=f"{settings.API_V1_STR}/analytics", tags=["analytics"]) +app.include_router( + analytics.router, prefix=f"{settings.API_V1_STR}/analytics", tags=["analytics"] +) # 新增路由 -app.include_router(admin_config.router, prefix=settings.API_V1_STR, tags=["admin-config"]) +app.include_router( + admin_config.router, prefix=settings.API_V1_STR, tags=["admin-config"] +) app.include_router(feature.router, prefix=settings.API_V1_STR, tags=["feature"]) app.include_router(checkin.router, prefix=settings.API_V1_STR, tags=["checkin"]) -app.include_router(user_profile.router, prefix=settings.API_V1_STR, tags=["user-profile"]) +app.include_router( + user_profile.router, prefix=settings.API_V1_STR, tags=["user-profile"] +) app.include_router(stats.router, prefix=settings.API_V1_STR, tags=["stats"]) app.include_router(algorithm.router, prefix=settings.API_V1_STR, tags=["algorithm"]) app.include_router(logs.router, prefix=settings.API_V1_STR, tags=["logs"]) app.include_router(ai_chat.router, prefix=settings.API_V1_STR, tags=["ai-chat"]) +app.include_router(ai_pattern.router, prefix=settings.API_V1_STR, tags=["ai-pattern"]) +app.include_router(ai_identify.router, prefix=settings.API_V1_STR, tags=["ai-identify"]) + # Health Check @app.get("/health") def health_check(): - return {"status": "healthy", "timestamp": datetime.now().isoformat(), "env": "development" if IS_DEV else "production"} + return { + "status": "healthy", + "timestamp": datetime.now().isoformat(), + "env": "development" if IS_DEV else "production", + } + # ========== Static Files (Development Only) ========== if IS_DEV: # Mount archives directory for download - app.mount("/download", StaticFiles(directory="archives"), name="download") + app.mount("/download", StaticFiles(directory=ARCHIVES_DIR), name="download") else: - print("ℹ️ Production Mode: Static files are NOT mounted by FastAPI (handled by Caddy/Nginx).") + print( + "ℹ️ Production Mode: Static files are NOT mounted by FastAPI (handled by Caddy/Nginx)." + ) + @app.get("/") def read_root(): from fastapi.responses import RedirectResponse + if IS_DEV: # 重定向到 Shell 登录页 return RedirectResponse(url="/shell/index.html") return {"message": "DesignerCEP API is running"} + @app.on_event("startup") def on_startup(): # 应用启动时初始化数据库(创建表) init_db() + for warning in settings.runtime_warnings(): + log.warning("[Config] %s", warning) + if __name__ == "__main__": import uvicorn + uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/Server/app/models/chat.py b/Server/app/models/chat.py index 7322278..5056a9b 100644 --- a/Server/app/models/chat.py +++ b/Server/app/models/chat.py @@ -15,6 +15,8 @@ class ChatSession(Base): user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) username = Column(String(64), nullable=False, index=True) title = Column(String(200), default="新对话") # 对话标题(取首条消息摘要) + active_skill_id = Column(String(80), nullable=True) + workflow_state = Column(Text, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/Server/app/models/user.py b/Server/app/models/user.py index 66a8519..ba6ec65 100644 --- a/Server/app/models/user.py +++ b/Server/app/models/user.py @@ -32,6 +32,17 @@ class User(Base): vip_expire = Column(DateTime(timezone=True), nullable=True) vip_daily_quota = Column(Integer, default=0) vip_quota_reset_date = Column(Date, nullable=True) + + # User-owned AI credentials (backend forwards requests using the user's own key) + ai_provider = Column(String(32), nullable=True) + ai_api_key = Column(Text, nullable=True) + ai_base_url = Column(String(255), nullable=True) + ai_chat_base_url = Column(String(255), nullable=True) + ai_vision_base_url = Column(String(255), nullable=True) + ai_image_base_url = Column(String(255), nullable=True) + ai_model = Column(String(120), nullable=True) + ai_vision_model = Column(String(120), nullable=True) + ai_image_model = Column(String(120), nullable=True) total_check_in_days = Column(Integer, default=0) consecutive_check_in = Column(Integer, default=0) diff --git a/Server/migrations/README.md b/Server/migrations/README.md new file mode 100644 index 0000000..7193682 --- /dev/null +++ b/Server/migrations/README.md @@ -0,0 +1,28 @@ +# 数据库迁移说明 + +当前项目保留两套迁移策略: + +1. `Alembic` + 适用于正式环境和多人协作,是推荐方案。 +2. `app/db.py -> ensure_migrations()` + 仅作为本地 SQLite 开发时的兜底补列逻辑。 + +## 推荐流程 + +```bash +cd Server +.venv\Scripts\python -m alembic upgrade head +``` + +新增模型字段后生成迁移: + +```bash +cd Server +.venv\Scripts\python -m alembic revision --autogenerate -m "describe_change" +``` + +注意事项: + +- `Server/alembic/env.py` 已按 `Server` 目录固定加载配置,不依赖当前启动目录。 +- 自动生成迁移前,先确认 `app/models/` 中新增模型已被 Alembic 导入。 +- 如果只是本机 SQLite 调试,项目仍会在启动时走一次轻量补列;但不要把它当作正式迁移方案。 diff --git a/Server/requirements.txt b/Server/requirements.txt index 1c48474..323922f 100644 --- a/Server/requirements.txt +++ b/Server/requirements.txt @@ -20,3 +20,4 @@ Pillow qiniu openai httpx +volcengine-python-sdk[ark] diff --git a/Server/test_identify_prompt.py b/Server/test_identify_prompt.py new file mode 100644 index 0000000..61fea07 --- /dev/null +++ b/Server/test_identify_prompt.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +""" +提示词迭代测试脚本 +直接调用视觉模型测试 identify_pieces 的提示词效果 +""" +import base64, json, os, sys + +# 加载环境 +sys.path.insert(0, os.path.dirname(__file__)) +from dotenv import load_dotenv +load_dotenv() + +from openai import OpenAI +from app.core.config import settings + +# ==================== 配置 ==================== + +GARMENT_IMG = "debug_images/1770525898_input_0.jpg" # 成衣照片 +CANVAS_IMG = "debug_images/1770525898_input_1.jpg" # 裁片轮廓 + +LAYERS_INFO = """组 1 (group, 4120x5280px, 位于路径: /组 1) +图层 1 (LayerKind.NORMAL, 4120x5280px, 位于路径: /组 1/图层 1) +组 2 (group, 3999x4561px, 位于路径: /组 2) +图层 2 (LayerKind.NORMAL, 3999x4561px, 位于路径: /组 2/图层 2) +组 3 (group, 3529x333px, 位于路径: /组 3) +图层 3 (LayerKind.NORMAL, 3529x333px, 位于路径: /组 3/图层 3) +组 4 (group, 2423x2004px, 位于路径: /组 4) +图层 4 (LayerKind.NORMAL, 2423x2004px, 位于路径: /组 4/图层 4) +组 5 (group, 2422x2003px, 位于路径: /组 5) +图层 1 拷贝 (LayerKind.NORMAL, 2422x2003px, 位于路径: /组 5/图层 1 拷贝)""" + +# ==================== 提示词版本 ==================== + +def make_prompt_v2(layers_info: str) -> str: + """v2 提示词:新增 pattern_mode + 不可见部位推理""" + # 动态提取图层名 + layer_names = [] + for line in layers_info.strip().split('\n'): + name = line.split('(')[0].strip() + if name: + layer_names.append(name) + ex1 = layer_names[0] if len(layer_names) > 0 else "图层名1" + ex2 = layer_names[1] if len(layer_names) > 1 else "图层名2" + ex3 = layer_names[2] if len(layer_names) > 2 else "图层名3" + + return f"""你需要完成三个任务: + +**任务1:识别裁片部位** +Image 2 是 PS 文档中的裁片轮廓图。以下是图层信息: +{layers_info} + +请根据形状、大小识别每个**图层组**是什么服装部位(前片、后片、袖子、领口等)。 +⚠️ mapping 的 key 必须是上面图层信息中的**实际图层组名称**(如 "{ex1}"、"{ex2}" 等),不要自己编名字! + +**任务2:判断花型覆盖模式 pattern_mode** +Image 1 是成衣照片。请判断这件衣服的花型属于哪种模式: + +- "all_over":所有裁片来自同一块面料(碎花/格子/渐变等整体花型覆盖全身) +- "placement":各裁片独立处理(有的印花有的纯色,如卡通卫衣) +- "color_block":全部纯色拼接 + +如果是 all_over,还需要提供 fabric_info: +- fabric_type: 面料印花类型(数码印花/提花/丝网印/...) +- pattern_direction: 花型方向(均匀铺满/上下渐变/左右渐变/斜向) +- pattern_elements: 花型元素描述 +- color_transition: 色彩过渡描述 +- density: 花型密度描述 + +**任务3:分析每个裁片的图案类型** +对每个裁片分析: +- type: solid / fill_pattern / theme_pattern / mixed_pattern / all_over +- solid 和 theme_pattern 必须给 color(hex值) +- 如果某个裁片在照片中**看不到**(如后片),设置 visible: false,并根据前片推理: + - 全身花型 → 后片与前片相同 + - 局部印花 → 后片通常为底色纯色 + - 给出推理理由 inference_reason 和置信度 confidence(high/medium/low) +- 如果左右对称(如左右袖),标记 mirror_of 字段 + +用 JSON 回答(注意:key 用实际图层名): +```json +{{ + "pattern_mode": "all_over", + "fabric_info": {{ + "fabric_type": "数码印花", + "pattern_direction": "上下渐变", + "pattern_elements": "白色百合花朵 + 灰白格纹底", + "color_transition": "从黑色渐变到灰白色", + "density": "上部花朵密集大朵,下部稀疏小朵" + }}, + "mapping": {{ + "{ex1}": "左袖", + "{ex2}": "前片" + }}, + "analysis": [ + {{"layer": "{ex1}", "piece": "左袖", "type": "all_over", "visible": true, "description": "黑底白花渐变"}}, + {{"layer": "{ex2}", "piece": "前片", "type": "all_over", "visible": true, "description": "上半黑底大花+下半灰白格纹"}}, + {{"layer": "{ex3}", "piece": "后片", "type": "all_over", "visible": false, "inferred_from": "{ex2}", "inference_reason": "全身花型面料,后片花型与前片相同", "confidence": "high"}} + ], + "symmetric_pairs": [["{ex1}", "其他对称片"]], + "base_color": "#1A1A1A" +}} +``` + +⚠️ 重要: +- mapping 和 analysis 中的 layer 必须用**实际图层组名称** +- 看不到的部位设 visible: false 并说明推理依据 +- 左右对称的片标记 symmetric_pairs +只返回 JSON,不要其他文字。""" + + +# ==================== 调用模型 ==================== + +def call_vision(prompt: str, garment_b64: str, canvas_b64: str, model: str = None): + """调用视觉模型""" + use_model = model or settings.AI_VISION_MODEL + client = OpenAI( + api_key=settings.AI_API_KEY, + base_url=settings.AI_BASE_URL or "https://api.openai.com/v1", + ) + + content = [ + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{garment_b64}"}}, + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{canvas_b64}"}}, + {"type": "text", "text": prompt}, + ] + + print(f"\n{'='*60}") + print(f"模型: {use_model}") + print(f"提示词长度: {len(prompt)} 字符") + print(f"{'='*60}\n") + + completion = client.chat.completions.create( + model=use_model, + messages=[{"role": "user", "content": content}], + ) + + return completion.choices[0].message.content or "" + + +def parse_result(raw: str) -> dict: + """解析 JSON 结果""" + clean = raw.strip() + if "```" in clean: + clean = clean.split("```")[1] + if clean.startswith("json"): + clean = clean[4:] + clean = clean.strip() + try: + return json.loads(clean) + except json.JSONDecodeError as e: + print(f"⚠️ JSON 解析失败: {e}") + print(f"原始回复:\n{raw[:500]}") + return {} + + +def evaluate_result(result: dict) -> list: + """评估结果质量,返回问题列表""" + issues = [] + + if not result: + issues.append("❌ 解析失败,无结果") + return issues + + # 检查 pattern_mode + mode = result.get("pattern_mode") + if not mode: + issues.append("❌ 缺少 pattern_mode") + elif mode not in ("all_over", "placement", "color_block"): + issues.append(f"⚠️ pattern_mode 值异常: {mode}") + + # 检查 mapping 是否用了真实图层名 + mapping = result.get("mapping", {}) + for key in mapping: + if key.startswith("M-") or key.startswith("Layer"): + issues.append(f"❌ mapping key '{key}' 不是真实图层名(应为 '组 X' 格式)") + + # 检查 analysis + analysis = result.get("analysis", []) + if not analysis: + issues.append("❌ 缺少 analysis") + else: + has_invisible = any(a.get("visible") == False for a in analysis) + if not has_invisible: + issues.append("⚠️ 没有标记不可见部位(后片通常看不到)") + + has_mirror = any("mirror_of" in a for a in analysis) + symmetric = result.get("symmetric_pairs", []) + if not has_mirror and not symmetric: + issues.append("⚠️ 没有标记对称片(左右袖应该对称)") + + for a in analysis: + if a.get("type") in ("solid", "theme_pattern") and not a.get("color"): + issues.append(f"⚠️ {a.get('layer')} 类型为 {a['type']} 但缺少 color") + + # 检查 fabric_info(仅 all_over) + if mode == "all_over": + fi = result.get("fabric_info") + if not fi: + issues.append("❌ all_over 模式缺少 fabric_info") + else: + for key in ("fabric_type", "pattern_direction", "pattern_elements", "color_transition", "density"): + if not fi.get(key): + issues.append(f"⚠️ fabric_info 缺少 {key}") + + if not issues: + issues.append("✅ 完美!所有字段齐全") + + return issues + + +# ==================== 主流程 ==================== + +def main(): + # 修复 Windows GBK 输出 + import io + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + + # 加载测试图片 + print("[1] Loading test images...") + with open(GARMENT_IMG, "rb") as f: + garment_b64 = base64.b64encode(f.read()).decode() + with open(CANVAS_IMG, "rb") as f: + canvas_b64 = base64.b64encode(f.read()).decode() + print(f" garment: {len(garment_b64)//1024}KB, canvas: {len(canvas_b64)//1024}KB") + + # 生成提示词 + prompt = make_prompt_v2(LAYERS_INFO) + + # 调用模型 + print("\n[2] Calling vision model...") + raw = call_vision(prompt, garment_b64, canvas_b64) + + # 解析 + print("\n[3] Model response:") + print(raw[:2000]) + print("\n" + "="*60) + + result = parse_result(raw) + + if result: + print("\n[4] Parsed result:") + print(json.dumps(result, ensure_ascii=False, indent=2)) + + # 评估 + print("\n[5] Quality check:") + issues = evaluate_result(result) + for issue in issues: + print(f" {issue}") + + # ==================== 阶段 2: Gemini 生图 ==================== + if result and result.get("pattern_mode") == "all_over" and result.get("fabric_info"): + print("\n" + "="*60) + print("[6] Testing Gemini image generation (all_over fabric)...") + + fi = result["fabric_info"] + gen_prompt = ( + f"Please generate a high-resolution flat fabric pattern image based on this garment photo.\n" + f"\n" + f"Requirements:\n" + f"1. Output a RECTANGULAR flat fabric image (not garment shape, just the fabric laid flat)\n" + f"2. Fabric type: {fi.get('fabric_type', 'digital print')}\n" + f"3. Pattern elements: {fi.get('pattern_elements', 'floral')}\n" + f"4. Pattern direction: {fi.get('pattern_direction', 'uniform')}\n" + f"5. Color transition: {fi.get('color_transition', 'none')}\n" + f"6. Pattern density: {fi.get('density', 'medium')}\n" + f"7. Remove all garment wrinkles/shadows - show flat fabric only\n" + f"8. The pattern must be clear, colors accurate, suitable for garment piece overlay\n" + f"9. Aspect ratio: 3:4\n" + f"10. The image should look like a piece of fabric photographed from directly above on a flat surface" + ) + + print(f" Prompt: {gen_prompt[:200]}...") + print(f" Model: gemini-3-pro-image-preview") + + # 用 Gemini OpenAI 兼容方式生图 + from openai import OpenAI as GeminiClient + gemini = GeminiClient( + api_key=settings.GEMINI_API_KEY, + base_url=f"{settings.GEMINI_BASE_URL}/v1", + ) + + content_parts = [ + {"type": "text", "text": gen_prompt}, + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{garment_b64}"}}, + ] + + print(" Calling Gemini 3 Pro Image...") + import time + t0 = time.time() + + completion = gemini.chat.completions.create( + model="gemini-3-pro-image-preview", + messages=[{"role": "user", "content": content_parts}], + ) + + elapsed = time.time() - t0 + resp_content = completion.choices[0].message.content or "" + print(f" Response length: {len(resp_content)} chars, time: {elapsed:.1f}s") + + # 提取图片 + import re + match = re.search(r'!\[.*?\]\((data:image/(\w+);base64,([^)]+))\)', resp_content) + if not match: + match = re.search(r'(data:image/(\w+);base64,([A-Za-z0-9+/=]+))', resp_content) + + if match: + img_format = match.group(2) + img_b64 = match.group(3) + # 修复 padding + padding = 4 - len(img_b64) % 4 + if padding != 4: + img_b64 += '=' * padding + + # 保存 + out_path = f"debug_images/test_fabric_allover.{img_format}" + with open(out_path, "wb") as f: + f.write(base64.b64decode(img_b64)) + print(f" [OK] Fabric image saved: {out_path} ({len(img_b64)//1024}KB)") + else: + print(f" [FAIL] No image in response") + if resp_content: + print(f" Response preview: {resp_content[:300]}") + + print(f"\n{'='*60}") + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/docs/AI智能套图方案v2.md b/docs/AI智能套图方案v2.md new file mode 100644 index 0000000..7fac903 --- /dev/null +++ b/docs/AI智能套图方案v2.md @@ -0,0 +1,405 @@ +# AI 智能套图方案 v2 + +> 基于 v1 实践反馈的完整升级方案 + +--- + +## 1. 核心问题 + +v1 方案对所有裁片都采用「逐片 crop → refine → place」的流程,存在以下问题: + +| 问题 | 影响 | +|------|------| +| 全身花型衣服每片单独生成,花型不连贯 | 效果差 | +| 每片调用一次 AI,5 片衣服 = 5+ 次 API | 慢、贵 | +| 左右袖花型不一致(AI 每次生成不同) | 效果差 | +| AI 容易陷入 identify → preview 循环 | 流程卡住 | +| 渐变花型无法正确还原 | 效果差 | + +--- + +## 2. 服装花型分类 + +### 2.1 按花型覆盖方式分 + +| 模式 | 英文标识 | 说明 | 示例 | +|------|---------|------|------| +| **全身花型** | `all_over` | 所有裁片来自同一块面料 | 碎花连衣裙、格子衬衫、渐变花T | +| **局部印花** | `placement` | 各部位独立处理 | 卡通卫衣(前片印花+袖子纯色) | +| **纯色拼接** | `color_block` | 全部纯色,无花型 | 撞色 POLO 衫 | + +### 2.2 按花型特征分(全身花型的子分类) + +| 特征 | 说明 | 生图策略 | +|------|------|---------| +| **均匀铺满** | 碎花、波点、小格子 | 生成一个重复单元 → PS 图案填充 | +| **大面积图案** | 大花、大格纹 | 生成一张大尺寸面料图 → 覆盖裁片 | +| **渐变过渡** | 上深下浅、色彩渐变 | 生成一张带渐变的面料图 | +| **方向性** | 条纹、斜纹 | 需要指定花型方向 | + +--- + +## 3. 升级后的套图流程 + +### 3.1 总流程 + +``` +用户上传成衣图 + 点击"帮我套图" + │ + ▼ + ┌─────────────────┐ + │ 阶段 0: 智能分析 │ ← 新增 + │ 判断花型模式 │ + │ all_over / placement / color_block + └────────┬────────┘ + │ + ┌─────┼──────┐ + ▼ ▼ ▼ + all_over placement color_block + │ │ │ + ▼ ▼ ▼ + 阶段 1A 阶段 1B 直接填色 + 生成面料图 逐片生成 (不需要 AI 生图) + │ │ + ▼ ▼ + 用户确认预览 + │ + ▼ + 阶段 2: 正式套图 +``` + +### 3.2 all_over 模式(全身花型) + +``` +1. identify_pieces + → 返回 pattern_mode: "all_over" + → 返回花型描述(渐变方向、花型元素、色彩等) + +2. 生成面料图(1 次 AI 调用) + → 提示词包含:面料类型、花型方向、元素、色彩、密度 + → 输出一张高清矩形面料图 + +3. 预览:将面料图覆盖到所有裁片上 + → 用户确认 + +4. 正式套图: + → 同一张面料图 place 到每个裁片 + → 每片只调整位置/缩放 + 剪切蒙版 + → 左右袖:同一张图,右袖水平翻转 + → 不需要 refine(已经是干净的面料图) +``` + +### 3.3 placement 模式(局部印花) + +``` +沿用 v1 的逻辑,但优化: +1. identify_pieces + → 返回 pattern_mode: "placement" + → 每片的 type: solid / fill_pattern / theme_pattern / mixed_pattern + +2. 生成预览(1 次 AI 调用) + +3. 用户确认后,逐片处理: + → solid: PS 填色 + → theme_pattern: 先放主题(白底+正片叠底) 再填底色 + → fill_pattern: crop → refine → place + → mixed_pattern: 两层(花型底 + 主题白底) + +优化点: + → 左右对称片只处理一片,另一片复制+翻转 + → 相同类型的片复用同一次 AI 结果 +``` + +### 3.4 color_block 模式(纯色拼接) + +``` +最简单: +1. identify_pieces → 所有片都是 solid + color +2. 不需要生成预览 +3. 直接 PS 填色 +``` + +--- + +## 4. 提示词改进 + +### 4.1 identify_pieces 提示词(新增 pattern_mode) + +``` +任务1 不变(识别裁片部位) + +任务2 改进: +分析成衣的花型覆盖模式 pattern_mode: + +- "all_over":所有裁片来自同一块面料(碎花/格子/渐变等整体花型) + → 需要额外描述: + - fabric_type: 数码印花/提花/梭织/针织 + - pattern_direction: 均匀/上下渐变/左右渐变/斜向 + - pattern_elements: 花朵/几何/条纹/格子/... + - color_transition: 从XX色渐变到XX色 / 无渐变 + - density: 密集/稀疏/上密下疏/... + +- "placement":各裁片独立(有的印花有的纯色) + → 每片单独分析 type + +- "color_block":全部纯色拼接 + → 每片给出 color + +输出 JSON 新增 pattern_mode 和 fabric_info 字段 +``` + +### 4.2 全身花型的生图提示词 + +``` +请根据这件成衣照片,生成一张高清面料花型平铺图。 + +要求: +1. 输出一张大的矩形花型图(不是裁片形状,而是一块平铺的面料) +2. 面料类型:{fabric_type} +3. 花型元素:{pattern_elements} +4. 花型方向:{pattern_direction} +5. 色彩过渡:{color_transition} +6. 花型密度:{density} +7. 去掉所有服装褶皱/阴影效果,呈现平铺面料的样子 +8. 图案要清晰、颜色准确、可以用于裁片套图 +9. 宽高比: {aspect_ratio} +``` + +### 4.3 局部印花的生图提示词(分 type 优化) + +| type | 提示词重点 | +|------|-----------| +| fill_pattern | 花型铺满矩形,保持连续无接缝 | +| theme_pattern | 主题图案居中,纯白底(#FFF) | +| mixed_fill | 只保留底纹,去掉主题图案 | +| mixed_theme | 只保留主题图案,纯白底 | + +--- + +## 5. 不可见部位的智能推理 + +### 5.1 问题 + +成衣照片通常只拍正面,**后片、内衬、侧面**等部位看不到。AI 不能瞎猜,需要根据前面的信息来推理。 + +### 5.2 推理规则 + +| 前片情况 | 后片推理 | 理由 | +|---------|---------|------| +| **全身花型**(碎花/格子/条纹) | 后片 = **同花型** | 同一块面料裁的,花型一样 | +| **前片有大面积主题印花**(卡通/Logo) | 后片 = **纯色**(用底色) | 99% 的衣服背面不印主图 | +| **前片纯色** | 后片 = **同色纯色** | 一样的颜色 | +| **渐变花型** | 后片 = **同花型或纯色** | 看具体款式,需要让用户确认 | +| **前片拼接**(上下不同花型) | 后片 = **取上半部分花型或底色** | 常见做法 | + +### 5.3 identify_pieces 输出新增字段 + +```json +{ + "analysis": [ + { + "layer": "组 4", "piece": "后片", + "type": "all_over", + "visible": false, + "inferred_from": "组 3", + "inference_reason": "全身花型面料,后片花型与前片相同", + "confidence": "high" + }, + { + "layer": "组 4", "piece": "后片", + "type": "solid", + "visible": false, + "color": "#F5E6D0", + "inferred_from": "组 3", + "inference_reason": "前片为局部卡通印花,后片通常为底色纯色", + "confidence": "medium" + } + ] +} +``` + +新增字段: +- `visible`: 该部位在成衣照片中是否可见(false = AI 推理的) +- `inferred_from`: 推理依据(哪个裁片) +- `inference_reason`: 推理理由(中文,展示给用户) +- `confidence`: 推理置信度(high/medium/low) + +### 5.4 用户确认环节 + +当存在 `visible: false` 的裁片时,AI 应该**明确告诉用户**: + +``` +✅ 可见部位分析完成: +- 组 1 左袖:黑底白花(主题图案) +- 组 2 右袖:同左袖(镜像) +- 组 3 前片:上半黑底大花 + 下半灰白格纹 +- 组 5 领口:纯黑色 + +⚠️ 以下部位在照片中不可见,我根据经验推测: +- 组 4 后片:推测与前片相同花型(全身印花面料) + → 如果后片其实是纯色,请告诉我 + +请确认后我开始套图。 +``` + +### 5.5 提示词中的推理指引 + +``` +如果某个裁片在成衣照片中看不到(如后片、内衬): +1. 设置 visible: false +2. 根据以下规则推理: + - 全身花型(all_over):不可见部位使用相同花型 + - 局部印花(placement):不可见部位通常为底色纯色(solid) + - 纯色拼接(color_block):不可见部位使用相同或相近颜色 +3. 给出推理理由和置信度 +4. 置信度为 low 时,必须让用户确认 +``` + +--- + +## 6. 镜像与复用优化 + +### 5.1 左右对称片 + +``` +识别到左袖+右袖时: +→ 只生成左袖的花型 +→ 右袖 = 左袖的水平翻转(JSX flip) +→ 省一次 AI 调用 +``` + +### 5.2 前后片 + +``` +如果前后片花型相同(all_over 模式下通常如此): +→ 同一张面料图 place 两次(不同位置) +→ 不需要分别生成 +``` + +### 5.3 相同纯色片 + +``` +v1 已有颜色归一化(RGB 距离 < 40 视为相同) +→ 保持 +``` + +--- + +## 6. System Prompt 优化 + +### 6.1 防止 AI 循环调用 + +``` +## 严格禁止 +- ❌ 预览生成后不要再次调用 identify_pieces +- ❌ 不要在同一次对话中多次生成预览(除非用户明确要求重新生成) +- ❌ 不要自作主张修改分析结果后重新执行(应该告诉用户发现的问题,让用户决定) + +## 流程纪律 +- 每个工具最多调用 1 次(除非上次失败) +- identify_pieces → generate_preview → 等用户确认 → extract_and_apply_all_pieces +- 这 4 步是固定顺序,不要跳步、不要回头 +``` + +### 6.2 自我纠错的正确方式 + +``` +如果在生成预览后发现分析可能有误: +→ ✅ 告诉用户:"我注意到袖口颜色可能判断有误,您要我重新分析吗?" +→ ❌ 不要自己偷偷重新调用 identify_pieces +``` + +--- + +## 7. identify_pieces 输出格式(v2) + +```json +{ + "pattern_mode": "all_over", + "fabric_info": { + "fabric_type": "数码印花", + "pattern_direction": "上下渐变", + "pattern_elements": "白色百合花朵", + "color_transition": "从黑色渐变到灰白格纹", + "density": "上部花朵密集,下部稀疏" + }, + "mapping": { + "组 1": "左袖", + "组 2": "右袖", + "组 3": "领口", + "组 4": "前片", + "组 5": "后片" + }, + "analysis": [ + {"layer": "组 1", "piece": "左袖", "type": "all_over", "description": "黑底白花渐变"}, + {"layer": "组 2", "piece": "右袖", "type": "all_over", "mirror_of": "组 1"}, + {"layer": "组 3", "piece": "领口", "type": "solid", "color": "#000000"}, + {"layer": "组 4", "piece": "前片", "type": "all_over", "description": "上半黑底花+下半灰白格纹"}, + {"layer": "组 5", "piece": "后片", "type": "all_over", "description": "同前片"} + ], + "symmetric_pairs": [["组 1", "组 2"]], + "base_color": "#1A1A1A" +} +``` + +新增字段: +- `pattern_mode`: all_over / placement / color_block +- `fabric_info`: 面料花型详细描述(仅 all_over 模式) +- `mirror_of`: 标记镜像片(省一次 AI 调用) +- `symmetric_pairs`: 对称片配对 + +--- + +## 8. 前端流程变化 + +### 8.1 all_over 模式 + +``` +extract_and_apply_all_pieces 新逻辑: + +1. 调用 AI 生成一张面料图(用 fabric_info 构建提示词) +2. 下载面料图 → 保存临时文件 +3. 遍历每个裁片: + a. 纯色片 → PS 填色 + b. all_over 片 → place 同一张面料图 + 缩放 + 剪切蒙版 + c. mirror_of 片 → 复制已套好的图层 + 水平翻转 +4. 清除标签 +``` + +### 8.2 placement 模式 + +``` +保持 v1 逻辑 + 镜像优化: + +1. 遍历裁片,按 type 处理 +2. 遇到 mirror_of 片时,跳过 AI,复制+翻转 +3. 其余逻辑不变 +``` + +--- + +## 9. 实现优先级 + +| 优先级 | 改动 | 预计工作量 | +|--------|------|-----------| +| **P0** | System Prompt 防循环 | 小(改提示词) | +| **P0** | identify 返回 pattern_mode | 小(改提示词+解析) | +| **P1** | all_over 模式生成面料图 | 中(新的生图逻辑) | +| **P1** | all_over 模式一图套多片 | 中(改 extract 逻辑) | +| **P2** | 镜像片优化(左右袖复用) | 小(JSX 复制+翻转) | +| **P2** | placement 模式提示词细化 | 小 | +| **P3** | 花型重复单元 → PS 图案填充 | 大(需要新的 JSX) | +| **P3** | 前后片花型对齐 | 大 | + +--- + +## 10. 对比总结 + +| | v1(现在) | v2(升级后) | +|---|---|---| +| 花型模式识别 | 无,统一处理 | 自动判断 all_over/placement/color_block | +| 全身花型 | 逐片生成(效果差) | 一张面料图覆盖所有片 | +| AI 调用次数 | 每片 1-2 次 | all_over: 仅 1 次 | +| 左右对称 | 分别生成(可能不一致) | 一次生成+镜像翻转 | +| 流程稳定性 | AI 可能循环调用 | 严格单向流程 | +| 渐变花型 | 无法正确还原 | 保留面料完整渐变 | diff --git a/docs/ai/ark-seedream-5-update.md b/docs/ai/ark-seedream-5-update.md new file mode 100644 index 0000000..a5fdbdb --- /dev/null +++ b/docs/ai/ark-seedream-5-update.md @@ -0,0 +1,44 @@ +# Ark Seedream 5.0 Update + +This project now aligns its generic image-generation flow with the current Ark Seedream 5.0 usage pattern. + +## What changed + +- Single-image text-to-image is supported through the generic design-image endpoint. +- Reference-image generation is supported by passing the latest uploaded chat image into the image model. +- Multi-image generation is supported with Ark sequential image generation. +- When the requested image count is greater than 1, the backend collects stream events and returns a final image list to the chat UI. + +## Runtime mapping + +- Backend image model entry: + - `Server/app/api/v1/ai_llm.py` + - Uses `client.images.generate(...)` + - Supports: + - `sequential_image_generation="disabled"` for single image + - `sequential_image_generation="auto"` for multi-image + - `SequentialImageGenerationOptions(max_images=...)` + +- Generic API endpoint: + - `POST /ai/generate-design-images` + - File: `Server/app/api/v1/ai_pattern.py` + +- Chat tool: + - `generate_design_images` + - File: `Server/app/api/v1/ai_tools.py` + +- Frontend executor: + - `Designer/src/utils/aiToolExecutor.ts` + +## Supported use cases + +- "生成一张高级感海报背景图" +- "根据这张参考图出 4 张不同方向稿" +- "生成一组连贯插画" +- "做 3 张 KV 草案给我选" + +## Notes + +- The current chat UI renders the returned image URLs directly inside the assistant conversation. +- The current implementation caps multi-image generation at 4 images per request. +- The project still uses the configured `AI_IMAGE_EDIT_MODEL` as the unified Ark image-generation model slot. diff --git a/docs/ai/openclaw-skill-adaptation.md b/docs/ai/openclaw-skill-adaptation.md new file mode 100644 index 0000000..28c7bff --- /dev/null +++ b/docs/ai/openclaw-skill-adaptation.md @@ -0,0 +1,53 @@ +# OpenClaw Skill Adaptation + +This project borrows ideas from selected skills in the [OpenClaw skills repo](https://github.com/openclaw/skills) and adapts them to DesignerCEP's Photoshop-first workflow. + +## Upstream skills used + +- `photoshop-automator` + - Source: `skills/abdul-karim-mia/photoshop-automator` + - URL: https://github.com/openclaw/skills/tree/main/skills/abdul-karim-mia/photoshop-automator + - What we adopted: + - Treat the active document as the primary execution context + - Warn about modal dialogs blocking Photoshop automation + - Require exact layer targeting instead of guessing + - Prefer short, reversible automation chains + +- `ui-ux-pro-max-0-1-0` + - Source: `skills/15349185792/ui-ux-pro-max-0-1-0` + - URL: https://github.com/openclaw/skills/tree/main/skills/15349185792/ui-ux-pro-max-0-1-0 + - What we adopted: + - Triage before execution + - Structured deliverables instead of vague design advice + - A design-system style way of thinking about layout, hierarchy, spacing, and goals + +- `ui-designer-skill` + - Source: `skills/1999azzar/ui-designer-skill` + - URL: https://github.com/openclaw/skills/tree/main/skills/1999azzar/ui-designer-skill + - What we adopted: + - Stronger visual-language analysis + - Style and palette vocabulary + - Better phrasing for aesthetic direction and accessibility-minded critique + +## Internal mapping in DesignerCEP + +- `ps-generalist` + - Strengthened with Photoshop automation guardrails from `photoshop-automator` + +- `ps-layout-designer` + - Strengthened with triage and deliverables from `ui-ux-pro-max` + - Enhanced with visual-language cues from `ui-designer-skill` + +- `creative-direction-strategist` + - New internal skill + - Uses image-aware design-direction analysis when the user provides a reference image + - Bridges "design thinking" into concrete Photoshop actions + +- `visual-analysis-advisor` + - Enriched with more designer-facing visual analysis language + +## Notes + +- We intentionally did not copy upstream skills verbatim into the product. +- The goal is to preserve DesignerCEP's current Vue + FastAPI + Photoshop-tool architecture while borrowing useful planning and safety patterns. +- Upstream skills are references; the runtime behavior is driven by `Server/app/api/v1/ai_skills.py` and `Server/app/api/v1/ai_llm.py`. diff --git a/AI改图-双图.py b/temp/AI改图-双图.py similarity index 100% rename from AI改图-双图.py rename to temp/AI改图-双图.py diff --git a/默认方案-POLO衫长袖-A料-已旋转.plt b/temp/默认方案-POLO衫长袖-A料-已旋转.plt similarity index 100% rename from 默认方案-POLO衫长袖-A料-已旋转.plt rename to temp/默认方案-POLO衫长袖-A料-已旋转.plt diff --git a/默认方案-POLO衫长袖-A料-标准.plt b/temp/默认方案-POLO衫长袖-A料-标准.plt similarity index 100% rename from 默认方案-POLO衫长袖-A料-标准.plt rename to temp/默认方案-POLO衫长袖-A料-标准.plt