feat: expand AI workflow support and refresh docs
This commit is contained in:
@@ -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 提交进仓库。
|
||||
|
||||
|
||||
@@ -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/云主机。
|
||||
- 生产建议限制上传文件大小,并在网关层增加鉴权与限流。
|
||||
|
||||
134
README.md
Normal file
134
README.md
Normal file
@@ -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 助手已开始拆分为头部 / 消息流 / 输入区组件,工具状态改为按会话隔离。
|
||||
40
Server/.env.example
Normal file
40
Server/.env.example
Normal file
@@ -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
|
||||
164
Server/README.md
164
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 与云存储密钥。
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
245
Server/app/api/v1/ai_identify.py
Normal file
245
Server/app/api/v1/ai_identify.py
Normal file
@@ -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}
|
||||
@@ -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 = 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)
|
||||
|
||||
client = OpenAI(
|
||||
api_key=settings.AI_API_KEY,
|
||||
base_url=settings.AI_BASE_URL or "https://api.openai.com/v1",
|
||||
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)}")
|
||||
|
||||
request_kwargs = {
|
||||
"model": use_model,
|
||||
"messages": messages,
|
||||
"temperature": 0.3,
|
||||
}
|
||||
if tools:
|
||||
request_kwargs.update(
|
||||
{
|
||||
"tools": tools,
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": False,
|
||||
}
|
||||
)
|
||||
|
||||
messages = [{"role": "system", "content": SYSTEM_PROMPT}] + messages_history
|
||||
|
||||
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)}")
|
||||
|
||||
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),
|
||||
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 message.content or "", tool_calls_data
|
||||
|
||||
return message.content or "", None
|
||||
|
||||
|
||||
# ==================== 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",
|
||||
"status": "pending",
|
||||
}
|
||||
)
|
||||
return getattr(message, "content", "") or "", tool_calls_data, skill.to_public_dict()
|
||||
|
||||
messages = []
|
||||
return getattr(message, "content", "") or "", None, skill.to_public_dict()
|
||||
|
||||
|
||||
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")
|
||||
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]}")
|
||||
|
||||
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 = {
|
||||
request_kwargs = {
|
||||
"model": use_model,
|
||||
"input": {"messages": [{"role": "user", "content": content_parts}]},
|
||||
"parameters": {"n": 1, "watermark": False, "prompt_extend": True}
|
||||
"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",
|
||||
}
|
||||
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {settings.AI_API_KEY}"}
|
||||
if image_payload is None:
|
||||
request_kwargs.pop("image")
|
||||
if image_count > 1:
|
||||
request_kwargs["sequential_image_generation_options"] = (
|
||||
SequentialImageGenerationOptions(max_images=image_count)
|
||||
)
|
||||
|
||||
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")
|
||||
log.info(f"[ImageModel] 提示词: {prompt[:200]}")
|
||||
if should_stream:
|
||||
stream_response = client.images.generate(**request_kwargs)
|
||||
images: List[dict] = []
|
||||
usage: Optional[dict] = None
|
||||
errors: List[str] = []
|
||||
|
||||
for idx, img_b64 in enumerate(images_b64):
|
||||
_save_debug_image(base64.b64decode(img_b64), f'input_{idx}.jpg')
|
||||
for event in stream_response:
|
||||
if event is None:
|
||||
continue
|
||||
|
||||
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}")
|
||||
event_type = str(getattr(event, "type", "") or "")
|
||||
event_error = _image_response_error_message(event)
|
||||
|
||||
data = resp.json()
|
||||
output = data.get("output", {})
|
||||
choices = output.get("choices", [])
|
||||
if not choices:
|
||||
raise ValueError("模型未返回结果")
|
||||
if event_type.endswith("partial_failed"):
|
||||
if event_error:
|
||||
errors.append(event_error)
|
||||
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"]
|
||||
|
||||
if not image_url:
|
||||
raise ValueError("模型未返回图片")
|
||||
|
||||
log.info(f"[ImageModel] 输出图片 URL: {image_url[:120]}...")
|
||||
if event_type.endswith("partial_succeeded"):
|
||||
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
|
||||
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]
|
||||
return call_vision_messages(
|
||||
verify_prompt,
|
||||
[garment_b64, canvas_b64],
|
||||
model_override=vision_model or settings.AI_VISION_MODEL,
|
||||
runtime_config=runtime_config,
|
||||
)
|
||||
|
||||
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 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}")
|
||||
|
||||
476
Server/app/api/v1/ai_pattern.py
Normal file
476
Server/app/api/v1/ai_pattern.py
Normal file
@@ -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()
|
||||
636
Server/app/api/v1/ai_skills.py
Normal file
636
Server/app/api/v1/ai_skills.py
Normal file
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
query = db.query(PltProcessRecord)\
|
||||
.filter(PltProcessRecord.user_id == user.id)
|
||||
|
||||
records = db.query(PltProcessRecord)\
|
||||
.filter(PltProcessRecord.user_id == user.id)\
|
||||
.filter(PltProcessRecord.created_at >= since)\
|
||||
.order_by(desc(PltProcessRecord.created_at))\
|
||||
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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,7 +48,7 @@ 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(
|
||||
@@ -48,52 +69,82 @@ else:
|
||||
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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -33,6 +33,17 @@ class User(Base):
|
||||
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)
|
||||
last_check_in_date = Column(Date, nullable=True)
|
||||
|
||||
28
Server/migrations/README.md
Normal file
28
Server/migrations/README.md
Normal file
@@ -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 调试,项目仍会在启动时走一次轻量补列;但不要把它当作正式迁移方案。
|
||||
@@ -20,3 +20,4 @@ Pillow
|
||||
qiniu
|
||||
openai
|
||||
httpx
|
||||
volcengine-python-sdk[ark]
|
||||
|
||||
329
Server/test_identify_prompt.py
Normal file
329
Server/test_identify_prompt.py
Normal file
@@ -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()
|
||||
405
docs/AI智能套图方案v2.md
Normal file
405
docs/AI智能套图方案v2.md
Normal file
@@ -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 可能循环调用 | 严格单向流程 |
|
||||
| 渐变花型 | 无法正确还原 | 保留面料完整渐变 |
|
||||
44
docs/ai/ark-seedream-5-update.md
Normal file
44
docs/ai/ark-seedream-5-update.md
Normal file
@@ -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.
|
||||
53
docs/ai/openclaw-skill-adaptation.md
Normal file
53
docs/ai/openclaw-skill-adaptation.md
Normal file
@@ -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`.
|
||||
Reference in New Issue
Block a user