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
|
```bash
|
||||||
cd AdminTool
|
cd AdminTool
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
启动完整工具:
|
||||||
|
|
||||||
## 🚀 一、自动化部署脚本 (auto_deploy_core.py)
|
|
||||||
|
|
||||||
### 功能说明
|
|
||||||
|
|
||||||
自动化完成以下所有步骤:
|
|
||||||
1. ✅ 构建前端(Shell + Core)
|
|
||||||
2. ✅ 打包 Shell.zip(供 CEP 扩展下载)
|
|
||||||
3. ✅ 上传到服务器(SSH/SFTP)
|
|
||||||
4. ✅ 更新 MySQL 数据库版本号
|
|
||||||
5. ✅ 清除本地缓存(测试用)
|
|
||||||
|
|
||||||
### 使用方法
|
|
||||||
|
|
||||||
#### 1. 首次使用 - 配置服务器
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python auto_deploy_core.py --version 1.0.6 --setup
|
python admin_gui.py
|
||||||
```
|
```
|
||||||
|
|
||||||
会提示你输入:
|
仅进入部署页:
|
||||||
- 服务器地址
|
|
||||||
- SSH 端口、用户名、密码
|
|
||||||
- 远程路径
|
|
||||||
- MySQL 地址、端口、用户名、密码、数据库名
|
|
||||||
|
|
||||||
配置会保存到 `deploy_config.json`,下次直接使用。
|
|
||||||
|
|
||||||
#### 2. 仅构建(不部署)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python auto_deploy_core.py --version 1.0.6
|
python deploy_tool.py
|
||||||
```
|
```
|
||||||
|
|
||||||
只在本地构建 Shell 和 Core,不上传到服务器。
|
## 3. 主要功能(基于当前代码)
|
||||||
|
|
||||||
#### 3. 构建并部署
|
### 3.1 后端 API 管理
|
||||||
|
|
||||||
```bash
|
- 连接后台(默认 API: `https://backend.aidg168.uk/api/v1`)
|
||||||
python auto_deploy_core.py --version 1.0.6 --deploy
|
- 用户组管理
|
||||||
```
|
- 用户权限管理
|
||||||
|
- 版本归档上传与分配
|
||||||
|
|
||||||
构建并自动上传到服务器:
|
### 3.2 服务器部署(SSH)
|
||||||
- Shell 在线登录页 → `/static/shell/`
|
|
||||||
- Core 核心应用 → `/static/core/1.0.6/`
|
|
||||||
- Shell.zip 下载包 → `/static/downloads/shell-1.0.6.zip`
|
|
||||||
|
|
||||||
#### 4. 构建、部署并更新数据库
|
- 通过 SSH/SFTP 上传后端代码到远程 `Server` 目录
|
||||||
|
- 远程执行 `docker-compose up -d --build`
|
||||||
|
- 查看容器状态、日志、健康检查
|
||||||
|
- 同步数据库结构(容器内执行脚本)
|
||||||
|
|
||||||
```bash
|
### 3.3 后端版本历史
|
||||||
python auto_deploy_core.py --version 1.0.6 --deploy --update-db
|
|
||||||
```
|
|
||||||
|
|
||||||
完整流程:构建 → 上传 → 更新 MySQL 数据库版本号。
|
- 记录部署版本
|
||||||
|
- 标记当前版本
|
||||||
|
- 回滚到历史版本
|
||||||
|
- 删除历史版本
|
||||||
|
|
||||||
**数据库更新 SQL:**
|
## 4. 配置文件
|
||||||
```sql
|
|
||||||
UPDATE plugin_groups SET current_version = '1.0.6' WHERE id = 1;
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5. 其他选项
|
工具读取 `deploy_config.json`。典型字段如下:
|
||||||
|
|
||||||
```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`:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -88,243 +62,24 @@ python auto_deploy_core.py --version 1.0.6 --setup
|
|||||||
"port": "22",
|
"port": "22",
|
||||||
"username": "root",
|
"username": "root",
|
||||||
"password": "your-password",
|
"password": "your-password",
|
||||||
"remote_path": "/var/www/DesignerCEP/Server/static",
|
"remote_path": "/root/Server"
|
||||||
"mysql": {
|
|
||||||
"host": "localhost",
|
|
||||||
"port": "3306",
|
|
||||||
"username": "root",
|
|
||||||
"password": "your-mysql-password",
|
|
||||||
"database": "designer_cep",
|
|
||||||
"table": "plugin_groups"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
如果本地没有该文件,可先在 GUI 中填写并保存配置。
|
||||||
|
|
||||||
## 🎨 二、图形化管理工具 (admin_gui.py)
|
## 5. 依赖
|
||||||
|
|
||||||
### 功能说明
|
`requirements.txt`:
|
||||||
|
|
||||||
提供可视化界面管理:
|
- `PyQt5`
|
||||||
- 用户组管理
|
- `requests`
|
||||||
- 用户权限管理
|
- `paramiko`
|
||||||
- 版本上传和分配
|
- `PyQt-Fluent-Widgets`
|
||||||
- **自动化部署**(新增)
|
|
||||||
|
|
||||||
### 使用方法
|
## 6. 使用建议
|
||||||
|
|
||||||
```bash
|
- 部署前先在目标服务器手工验证 `docker-compose` 可用。
|
||||||
python admin_gui.py
|
- 首次部署建议先使用“检查状态”确认容器健康。
|
||||||
```
|
- 生产环境不要将 SSH 密码和 Token 提交进仓库。
|
||||||
|
|
||||||
### 主要功能
|
|
||||||
|
|
||||||
#### 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
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,112 +1,61 @@
|
|||||||
# PLT 裁片处理微服务
|
# PLT 裁片处理微服务
|
||||||
|
|
||||||
独立的 PLT 文件处理服务,可部署到阿里云 SAE。
|
独立 FastAPI 服务,用于解析 PLT 并输出按尺码分组的裁片 PNG(Base64)。
|
||||||
|
|
||||||
## 本地运行
|
## 1. 启动方式
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 安装依赖
|
cd PltService
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# 启动服务
|
|
||||||
python main.py
|
python main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
服务启动后访问:http://localhost:8080
|
默认监听:`http://localhost:8080`
|
||||||
|
可通过环境变量 `PORT` 修改端口。
|
||||||
|
|
||||||
## Docker 构建
|
## 2. Docker 运行
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 构建镜像
|
cd PltService
|
||||||
docker build -t plt-service:latest .
|
docker build -t plt-service:latest .
|
||||||
|
|
||||||
# 运行容器
|
|
||||||
docker run -p 8080:8080 plt-service:latest
|
docker run -p 8080:8080 plt-service:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## 部署到阿里云 SAE
|
## 3. API
|
||||||
|
|
||||||
### 1. 构建并推送镜像到阿里云容器镜像服务
|
### 3.1 健康检查
|
||||||
|
|
||||||
```bash
|
- `GET /health`
|
||||||
# 登录阿里云容器镜像服务
|
|
||||||
docker login --username=<你的阿里云账号> registry.cn-hangzhou.aliyuncs.com
|
|
||||||
|
|
||||||
# 构建镜像
|
### 3.2 PLT 处理
|
||||||
docker build -t registry.cn-hangzhou.aliyuncs.com/<命名空间>/plt-service:v1.0 .
|
|
||||||
|
|
||||||
# 推送镜像
|
- `POST /process`
|
||||||
docker push registry.cn-hangzhou.aliyuncs.com/<命名空间>/plt-service:v1.0
|
- `Content-Type: multipart/form-data`
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
参数:
|
参数:
|
||||||
- file: PLT 文件
|
|
||||||
- size_labels: 尺码标签,如 ["S","M","L","XL","2XL"]
|
|
||||||
- dpi: 输出分辨率(默认 150)
|
|
||||||
- rotation: 旋转角度(0/90/-90/180)
|
|
||||||
```
|
|
||||||
|
|
||||||
响应示例:
|
- `file`:PLT 文件
|
||||||
```json
|
- `size_labels`:JSON 字符串(如 `["S","M","L","XL"]`)
|
||||||
{
|
- `dpi`:图片 DPI,默认 `150`
|
||||||
"success": true,
|
- `rotation`:旋转角度,支持 `0/90/-90/180`
|
||||||
"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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 费用估算(阿里云 SAE)
|
返回:
|
||||||
|
|
||||||
| 场景 | 费用 |
|
- `success`
|
||||||
|------|------|
|
- `total_groups`
|
||||||
| 处理 1 个 PLT(30秒) | ¥0.04 |
|
- `groups[]`(每组包含各尺码裁片的 Base64 图像和坐标信息)
|
||||||
| 每天 100 个 PLT | ¥4/天 |
|
|
||||||
| 无请求时 | ¥0(最小实例设为0) |
|
## 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/
|
Server/
|
||||||
├── app/ ← 后端代码(FastAPI)
|
├── app/ # FastAPI 代码
|
||||||
│ ├── api/ ← API 路由
|
│ ├── api/v1/ # 接口路由
|
||||||
│ ├── core/ ← 核心配置
|
│ ├── core/ # 配置与安全
|
||||||
│ ├── models/ ← 数据库模型
|
│ ├── models/ # ORM 模型
|
||||||
│ ├── schemas/ ← 数据验证
|
│ ├── schemas/ # Pydantic Schema
|
||||||
│ ├── services/ ← 业务逻辑
|
│ ├── services/ # 业务服务
|
||||||
│ ├── main.py ← 后端入口
|
│ ├── db.py # SQLAlchemy 初始化与 seed
|
||||||
│ └── db.py ← 数据库连接
|
│ └── main.py # 应用入口
|
||||||
│
|
├── archives/ # 上传版本包归档目录
|
||||||
├── static/ ← ⭐ 前端静态文件(重要!)
|
├── debug_images/ # 调试图片输出
|
||||||
│ └── app/ ← 前端构建产物放这里
|
├── docker-compose.yml # Docker 编排(backend + mysql + pma + portainer)
|
||||||
│ ├── index.html ← 前端入口
|
├── Dockerfile # 后端镜像
|
||||||
│ ├── assets/ ← JS/CSS/图片
|
├── requirements.txt # Python 依赖
|
||||||
│ └── ...
|
├── start.sh # 容器启动脚本
|
||||||
│
|
├── .env.example # 配置模板(推荐复制后本地填写)
|
||||||
├── archives/ ← Core 版本压缩包(历史版本)
|
└── .env # 运行配置(敏感)
|
||||||
│ ├── 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 数据库(本地开发用)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 2. 本地运行
|
||||||
|
|
||||||
## ⭐ 前端文件应该放在这里
|
|
||||||
|
|
||||||
### 构建前端后,复制到这里:
|
|
||||||
|
|
||||||
```
|
|
||||||
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 部署
|
|
||||||
|
|
||||||
### 启动服务
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd Server
|
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
|
```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
|
```bash
|
||||||
docker-compose down
|
docker-compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 4. 配置说明(来自 `app/core/config.py`)
|
||||||
|
|
||||||
## 📋 部署清单
|
主要环境变量(优先使用 `AI_*`):
|
||||||
|
|
||||||
- [ ] 前端文件已复制到 `static/app/`
|
- `ENV`(development / production)
|
||||||
- [ ] `.env` 文件已配置
|
- `DATABASE_URL`
|
||||||
- [ ] MySQL 密码已修改
|
- `SECRET_KEY`
|
||||||
- [ ] SECRET_KEY 已修改
|
- `ADMIN_TOKEN`
|
||||||
- [ ] ALLOWED_ORIGINS 已配置
|
- `ALLOWED_ORIGINS`
|
||||||
- [ ] Docker 服务已启动
|
- `SMTP_*`
|
||||||
- [ ] 访问 https://app.aidg168.uk/ 测试
|
- `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
|
import sys
|
||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import engine_from_config
|
from sqlalchemy import engine_from_config
|
||||||
from sqlalchemy import pool
|
from sqlalchemy import pool
|
||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
|
|
||||||
# 将当前目录添加到 sys.path,以便能够导入 app 模块
|
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||||
sys.path.append(os.getcwd())
|
if str(SERVER_DIR) not in sys.path:
|
||||||
|
sys.path.append(str(SERVER_DIR))
|
||||||
|
|
||||||
# 导入配置和模型
|
# 导入配置和模型
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
# 导入所有模型以确保它们被注册到 Base.metadata
|
# 导入所有模型以确保它们被注册到 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
|
# this is the Alembic Config object, which provides
|
||||||
# access to the values within the .ini file in use.
|
# 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 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
AI 模型调用层
|
AI 模型调用层
|
||||||
统一管理所有 LLM / Vision / Image 模型的调用逻辑
|
统一管理所有 AI 模型的调用逻辑
|
||||||
支持 Qwen (DashScope) 和 Gemini (第三方代理) 两套路由
|
当前默认 provider 为 Ark,配置项保持通用命名,并支持技能化执行策略
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
import json, base64, re, logging
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.api.v1.ai_tools import PS_TOOLS, TOOL_DISPLAY_NAMES
|
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__)
|
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 ====================
|
# ==================== Prompts ====================
|
||||||
|
|
||||||
SYSTEM_PROMPT = """你是 DesignerCEP 的 AI 助手,运行在 Adobe Photoshop CEP 插件中。
|
SYSTEM_PROMPT = """你是 DesignerCEP 的 AI 助手,运行在 Adobe Photoshop CEP 插件中。
|
||||||
@@ -20,395 +35,634 @@ SYSTEM_PROMPT = """你是 DesignerCEP 的 AI 助手,运行在 Adobe Photoshop
|
|||||||
1. 回答关于 Photoshop 操作和插件使用的问题
|
1. 回答关于 Photoshop 操作和插件使用的问题
|
||||||
2. 通过工具直接操作 Photoshop(创建图层、对齐、查看文档信息等)
|
2. 通过工具直接操作 Photoshop(创建图层、对齐、查看文档信息等)
|
||||||
3. 帮用户排查操作中遇到的错误
|
3. 帮用户排查操作中遇到的错误
|
||||||
4. **AI 智能套图**:两阶段流程 — 先生成预览确认,再提取套到裁片上
|
4. AI 智能套图:两阶段流程,先生成预览确认,再提取套到裁片上
|
||||||
|
|
||||||
## AI 智能套图流程(两阶段)
|
## AI 智能套图流程
|
||||||
|
|
||||||
当用户上传成衣图片并要求套图时:
|
当用户上传成衣图片并要求套图时:
|
||||||
|
1. 先调用 identify_pieces 识别每个图层是什么裁片部位
|
||||||
### 阶段 1 — 识别裁片 + 生成预览(需用户确认)
|
2. 告诉用户识别结果
|
||||||
1. 调用 **identify_pieces** — 截取画布并识别每个图层是什么裁片部位(前片、后片、袖子等)
|
3. 调用 generate_garment_preview 生成带标签的裁片预览图
|
||||||
2. 告诉用户识别结果(如 "M-1=前片, M-2=后片, M-5=左袖...")
|
4. 等用户确认 OK 后,再调用 extract_and_apply_all_pieces 正式套图
|
||||||
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 完成后必须**等用户说"可以"/"OK"**才执行阶段 2
|
- 预览生成后必须等用户确认,不能自己直接进入正式套图
|
||||||
- 用户可能会说"袖子纯色"、"后幅不要花样"等,要根据 identify_pieces 的结果对应到正确的图层名
|
- 每个工具最多调用 1 次,除非上次执行失败
|
||||||
|
- 用户要求执行 Photoshop 操作时,优先使用工具完成
|
||||||
重要规则:
|
- 用户如果是在问“不会怎么做 / 为什么不行 / 下一步怎么操作”,要优先结合当前 Photoshop 上下文答疑,不要只给脱离现场的教程
|
||||||
- 当用户要求执行 PS 操作时,使用工具完成
|
- 答疑、查询当前信息、执行操作可以在同一轮里完成,不要把它们人为拆成两种模式
|
||||||
- 执行操作前可以先了解当前文档和图层状态
|
- 如果用户的问题和当前文档、图层、选区、参考图有关,优先先查 1 个最相关的上下文工具,再给结论
|
||||||
- 用简洁的中文回答,适合在小面板中阅读
|
- 如果用户意图已经明确且风险低,可以边解释边执行;如果涉及删除、覆盖、关闭、合并等风险操作,先确认
|
||||||
- 如果工具执行失败,向用户解释原因并建议解决方案
|
- 不要只讲原理不动手,也不要只闷头执行不解释;默认输出“判断 + 当前检查/执行结果 + 下一步”
|
||||||
|
- 用简洁中文回答,适合在小面板中阅读
|
||||||
|
- 复杂任务先给 2-4 条简短思路,再开始执行
|
||||||
|
- 默认按 观察 -> 判断 -> 执行 -> 验证 的顺序做事
|
||||||
|
- 如果任务包含多步骤,不要一上来同时调很多工具,先完成当前阶段再进入下一阶段
|
||||||
|
- 回答里尽量显式说明“当前阶段”和“下一步”
|
||||||
"""
|
"""
|
||||||
|
|
||||||
VISION_PROMPT = """你是一位资深的服装设计分析师,同时也是 DesignerCEP 的 AI 助手。
|
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 _chat_system_prompt(skill: AiSkill, has_image: bool = False) -> str:
|
||||||
|
return f"{SYSTEM_PROMPT}\n\n{_build_skill_prompt(skill, has_image)}"
|
||||||
def is_gemini_model(model_name: str) -> bool:
|
|
||||||
"""判断是否是 Gemini 模型"""
|
|
||||||
return bool(model_name) and "gemini" in model_name.lower()
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 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
|
use_model = model_override or settings.AI_MODEL
|
||||||
|
client = get_ai_client(runtime_config, capability="chat")
|
||||||
client = OpenAI(
|
skill = get_ai_skill(skill_id) or resolve_ai_skill(
|
||||||
api_key=settings.AI_API_KEY,
|
message=str(messages_history[-1].get("content", "")) if messages_history else "",
|
||||||
base_url=settings.AI_BASE_URL or "https://api.openai.com/v1",
|
history_messages=messages_history[:-1],
|
||||||
|
requested_skill_id=skill_id,
|
||||||
|
has_image=has_image,
|
||||||
)
|
)
|
||||||
|
messages = _messages_with_system(messages_history, skill, has_image)
|
||||||
|
tools = _tool_schemas_for_skill(skill)
|
||||||
|
|
||||||
messages = [{"role": "system", "content": SYSTEM_PROMPT}] + messages_history
|
log.info(f"{'=' * 60}")
|
||||||
|
log.info(f"[LLM] provider={get_ai_provider(runtime_config)} model={use_model} source={(runtime_config.source if runtime_config else 'server')}")
|
||||||
|
log.info(f"[LLM] skill={skill.id}")
|
||||||
|
log.info(f"[LLM] 消息数量: {len(messages)}")
|
||||||
|
log.info(f"[LLM] 工具数量: {len(tools)}")
|
||||||
|
|
||||||
log.info(f"{'='*60}")
|
request_kwargs = {
|
||||||
log.info(f"[LLM] 调用工具模型: {use_model}{' (override)' if model_override else ''}")
|
"model": use_model,
|
||||||
log.info(f"[LLM] 消息数量: {len(messages)} (system + {len(messages_history)} history)")
|
"messages": messages,
|
||||||
for i, m in enumerate(messages_history[-5:]):
|
"temperature": 0.3,
|
||||||
role = m['role']
|
}
|
||||||
content = m['content'][:120] if m.get('content') else '(empty)'
|
if tools:
|
||||||
log.info(f"[LLM] history[-{len(messages_history)-i}] {role}: {content}")
|
request_kwargs.update(
|
||||||
log.info(f"[LLM] 工具数量: {len(PS_TOOLS)}")
|
{
|
||||||
|
"tools": tools,
|
||||||
|
"tool_choice": "auto",
|
||||||
|
"parallel_tool_calls": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
completion = client.chat.completions.create(
|
completion = client.chat.completions.create(**request_kwargs)
|
||||||
model=use_model,
|
|
||||||
messages=messages,
|
|
||||||
tools=PS_TOOLS,
|
|
||||||
tool_choice="auto",
|
|
||||||
)
|
|
||||||
|
|
||||||
choice = completion.choices[0]
|
choice = completion.choices[0]
|
||||||
message = choice.message
|
message = choice.message
|
||||||
|
tool_calls = getattr(message, "tool_calls", None) or []
|
||||||
|
|
||||||
log.info(f"[LLM] 响应 finish_reason={choice.finish_reason}")
|
if tool_calls:
|
||||||
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:
|
|
||||||
tool_calls_data = []
|
tool_calls_data = []
|
||||||
for tc in message.tool_calls:
|
for tc in tool_calls:
|
||||||
args = {}
|
args = {}
|
||||||
if tc.function.arguments:
|
function = getattr(tc, "function", None)
|
||||||
|
raw_args = getattr(function, "arguments", "") if function else ""
|
||||||
|
if raw_args:
|
||||||
try:
|
try:
|
||||||
args = json.loads(tc.function.arguments)
|
args = json.loads(raw_args)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
args = {}
|
args = {}
|
||||||
tool_calls_data.append({
|
function_name = getattr(function, "name", "") if function else ""
|
||||||
"id": tc.id,
|
tool_calls_data.append(
|
||||||
"name": tc.function.name,
|
{
|
||||||
"display_name": TOOL_DISPLAY_NAMES.get(tc.function.name, tc.function.name),
|
"id": getattr(tc, "id", function_name),
|
||||||
"args": args,
|
"name": function_name,
|
||||||
"status": "pending"
|
"display_name": TOOL_DISPLAY_NAMES.get(function_name, function_name),
|
||||||
})
|
"args": args,
|
||||||
return message.content or "", tool_calls_data
|
"status": "pending",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return getattr(message, "content", "") or "", tool_calls_data, skill.to_public_dict()
|
||||||
|
|
||||||
return message.content or "", None
|
return getattr(message, "content", "") or "", None, skill.to_public_dict()
|
||||||
|
|
||||||
|
|
||||||
# ==================== Gemini 调用(OpenAI 兼容代理) ====================
|
def call_gemini(messages_history: List[dict], model: str, images_b64: List[str] = None):
|
||||||
|
"""兼容旧接口,内部统一转到当前视觉模型。"""
|
||||||
def call_gemini(messages_history: List[dict], model: str, images_b64: List[str] = None) -> str:
|
prompt = ""
|
||||||
"""调用 Gemini(通过第三方代理,OpenAI 兼容格式)"""
|
|
||||||
from openai import OpenAI as _OpenAI
|
|
||||||
|
|
||||||
if not settings.GEMINI_API_KEY or not settings.GEMINI_BASE_URL:
|
|
||||||
raise ValueError("GEMINI_API_KEY 或 GEMINI_BASE_URL 未配置")
|
|
||||||
|
|
||||||
client = _OpenAI(
|
|
||||||
api_key=settings.GEMINI_API_KEY,
|
|
||||||
base_url=f"{settings.GEMINI_BASE_URL}/v1",
|
|
||||||
)
|
|
||||||
|
|
||||||
messages = []
|
|
||||||
for msg in messages_history:
|
for msg in messages_history:
|
||||||
role = msg.get("role", "user")
|
if msg.get("role") == "user" and msg.get("content"):
|
||||||
content = msg.get("content", "")
|
prompt = str(msg["content"])
|
||||||
if role not in ("system", "user", "assistant"):
|
|
||||||
role = "user"
|
|
||||||
messages.append({"role": role, "content": content})
|
|
||||||
|
|
||||||
if images_b64:
|
if images_b64:
|
||||||
last_user_idx = None
|
return call_vision_messages(prompt or "请分析这张图片。", images_b64, model)
|
||||||
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
|
|
||||||
|
|
||||||
log.info(f"{'='*60}")
|
reply, _, _ = call_llm_with_tools(messages_history, model_override=model)
|
||||||
log.info(f"[Gemini] 调用模型: {model} (OpenAI 兼容)")
|
return reply
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def call_gemini_with_tools(messages_history: List[dict], model: str) -> tuple:
|
def call_gemini_with_tools(messages_history: List[dict], model: str) -> tuple:
|
||||||
"""用 Gemini 做对话(不支持 function calling)"""
|
"""兼容旧接口,内部统一转到当前工具调用。"""
|
||||||
full_history = [{"role": "system", "content": SYSTEM_PROMPT}] + messages_history
|
return call_llm_with_tools(messages_history, model_override=model)
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 视觉模型 ====================
|
# ==================== 视觉模型 ====================
|
||||||
|
|
||||||
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
|
use_model = model_override or settings.AI_VISION_MODEL
|
||||||
|
client = get_ai_client(runtime_config, capability="vision")
|
||||||
|
|
||||||
if is_gemini_model(use_model):
|
content = []
|
||||||
log.info(f"[Vision] 使用 Gemini 视觉模型: {use_model}")
|
for image_b64 in images_b64:
|
||||||
msgs = [{"role": "system", "content": VISION_PROMPT}]
|
content.append({"type": "input_image", "image_url": _image_data_url(image_b64)})
|
||||||
for h in history[-10:]:
|
content.append({"type": "input_text", "text": user_message})
|
||||||
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])
|
|
||||||
|
|
||||||
from openai import OpenAI
|
response = client.responses.create(
|
||||||
client = OpenAI(api_key=settings.AI_API_KEY, base_url=settings.AI_BASE_URL or "https://api.openai.com/v1")
|
model=use_model,
|
||||||
|
instructions=instructions_override or VISION_PROMPT,
|
||||||
|
input=[{"role": "user", "content": content}],
|
||||||
|
)
|
||||||
|
|
||||||
user_content = [
|
result = _extract_response_text(response)
|
||||||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_base64}"}},
|
log.info(
|
||||||
{"type": "text", "text": user_message}
|
f"[Vision] provider={get_ai_provider(runtime_config)} model={use_model} 输出长度: {len(result)}"
|
||||||
]
|
)
|
||||||
messages = [{"role": "system", "content": VISION_PROMPT}]
|
return result
|
||||||
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 ""
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 图片编辑/生成模型 ====================
|
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
|
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 兼容代理) ----------
|
image_inputs = [_image_data_url(img_b64) for img_b64 in images_b64] or None
|
||||||
if is_gemini_model(use_model):
|
image_payload = None
|
||||||
log.info(f"[ImageModel] 使用 Gemini 图片模型: {use_model}")
|
if image_inputs:
|
||||||
if not settings.GEMINI_API_KEY or not settings.GEMINI_BASE_URL:
|
image_payload = image_inputs[0] if len(image_inputs) == 1 else image_inputs
|
||||||
raise ValueError("GEMINI_API_KEY 或 GEMINI_BASE_URL 未配置")
|
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')}")
|
||||||
from openai import OpenAI as _OpenAI
|
log.info(f"[ImageModel] 图片数: {len(images_b64)}")
|
||||||
client = _OpenAI(api_key=settings.GEMINI_API_KEY, base_url=f"{settings.GEMINI_BASE_URL}/v1")
|
log.info(f"[ImageModel] 目标生成张数: {image_count}")
|
||||||
|
|
||||||
content_parts = [{"type": "text", "text": prompt}]
|
|
||||||
for img_b64 in images_b64:
|
|
||||||
content_parts.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}})
|
|
||||||
|
|
||||||
log.info(f"[ImageModel] Gemini OpenAI 兼容, 模型: {use_model}")
|
|
||||||
log.info(f"[ImageModel] 图片数: {len(images_b64)}, 各图: {[len(b)//1024 for b in images_b64]}KB")
|
|
||||||
log.info(f"[ImageModel] 提示词: {prompt[:200]}")
|
|
||||||
|
|
||||||
completion = client.chat.completions.create(model=use_model, messages=[{"role": "user", "content": content_parts}])
|
|
||||||
result_content = completion.choices[0].message.content or ""
|
|
||||||
log.info(f"[ImageModel] Gemini 回复长度: {len(result_content)} chars")
|
|
||||||
|
|
||||||
# 提取 base64 图片
|
|
||||||
match = re.search(r'!\[.*?\]\((data:image/(\w+);base64,([^)]+))\)', result_content)
|
|
||||||
if not match:
|
|
||||||
match = re.search(r'(data:image/(\w+);base64,([A-Za-z0-9+/=]+))', result_content)
|
|
||||||
if not match:
|
|
||||||
log.warning(f"[ImageModel] Gemini 响应中无图片,前500字: {result_content[:500]}")
|
|
||||||
raise ValueError("Gemini 未返回图片,请检查模型是否支持图片生成")
|
|
||||||
|
|
||||||
img_format = match.group(2)
|
|
||||||
image_b64 = match.group(3)
|
|
||||||
padding = 4 - len(image_b64) % 4
|
|
||||||
if padding != 4:
|
|
||||||
image_b64 += '=' * padding
|
|
||||||
|
|
||||||
log.info(f"[ImageModel] Gemini 返回图片: image/{img_format}, {len(image_b64)//1024}KB")
|
|
||||||
_save_debug_image(base64.b64decode(image_b64), f'gemini_output.{img_format}')
|
|
||||||
|
|
||||||
description = re.sub(r'!\[.*?\]\(data:image/[^)]+\)', '', result_content).strip()
|
|
||||||
return f"data:image/{img_format};base64,{image_b64}", description
|
|
||||||
|
|
||||||
# ---------- DashScope 原生接口 ----------
|
|
||||||
api_url = "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
|
|
||||||
content_parts = []
|
|
||||||
for img_b64 in images_b64:
|
|
||||||
content_parts.append({"image": f"data:image/jpeg;base64,{img_b64}"})
|
|
||||||
content_parts.append({"text": prompt})
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"model": use_model,
|
|
||||||
"input": {"messages": [{"role": "user", "content": content_parts}]},
|
|
||||||
"parameters": {"n": 1, "watermark": False, "prompt_extend": True}
|
|
||||||
}
|
|
||||||
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {settings.AI_API_KEY}"}
|
|
||||||
|
|
||||||
log.info(f"{'='*60}")
|
|
||||||
log.info(f"[ImageModel] DashScope 原生 API, 模型: {use_model}")
|
|
||||||
log.info(f"[ImageModel] 图片数: {len(images_b64)}, 各图: {[len(b)//1024 for b in images_b64]}KB")
|
|
||||||
log.info(f"[ImageModel] 提示词: {prompt[:200]}")
|
log.info(f"[ImageModel] 提示词: {prompt[:200]}")
|
||||||
|
|
||||||
for idx, img_b64 in enumerate(images_b64):
|
request_kwargs = {
|
||||||
_save_debug_image(base64.b64decode(img_b64), f'input_{idx}.jpg')
|
"model": use_model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"image": image_payload,
|
||||||
|
"response_format": "url",
|
||||||
|
"size": size or "2K",
|
||||||
|
"watermark": True,
|
||||||
|
"stream": should_stream,
|
||||||
|
"sequential_image_generation": "auto" if image_count > 1 else "disabled",
|
||||||
|
}
|
||||||
|
if image_payload is None:
|
||||||
|
request_kwargs.pop("image")
|
||||||
|
if image_count > 1:
|
||||||
|
request_kwargs["sequential_image_generation_options"] = (
|
||||||
|
SequentialImageGenerationOptions(max_images=image_count)
|
||||||
|
)
|
||||||
|
|
||||||
resp = http_requests.post(api_url, json=payload, headers=headers, timeout=120)
|
if should_stream:
|
||||||
if resp.status_code != 200:
|
stream_response = client.images.generate(**request_kwargs)
|
||||||
error_data = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
images: List[dict] = []
|
||||||
err_msg = error_data.get("message", resp.text[:300])
|
usage: Optional[dict] = None
|
||||||
log.error(f"[ImageModel] API 错误: {resp.status_code} {err_msg}")
|
errors: List[str] = []
|
||||||
if "data_inspection_failed" in str(error_data):
|
|
||||||
raise ValueError("图片内容未通过安全审核,请更换图片")
|
|
||||||
raise ValueError(f"图片模型调用失败({resp.status_code}): {err_msg}")
|
|
||||||
|
|
||||||
data = resp.json()
|
for event in stream_response:
|
||||||
output = data.get("output", {})
|
if event is None:
|
||||||
choices = output.get("choices", [])
|
continue
|
||||||
if not choices:
|
|
||||||
raise ValueError("模型未返回结果")
|
|
||||||
|
|
||||||
content_list = choices[0].get("message", {}).get("content", [])
|
event_type = str(getattr(event, "type", "") or "")
|
||||||
image_url = None
|
event_error = _image_response_error_message(event)
|
||||||
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:
|
if event_type.endswith("partial_failed"):
|
||||||
raise ValueError("模型未返回图片")
|
if event_error:
|
||||||
|
errors.append(event_error)
|
||||||
|
continue
|
||||||
|
|
||||||
log.info(f"[ImageModel] 输出图片 URL: {image_url[:120]}...")
|
if event_type.endswith("partial_succeeded"):
|
||||||
try:
|
try:
|
||||||
out_resp = http_requests.get(image_url, timeout=60)
|
images.append(
|
||||||
if out_resp.status_code == 200:
|
_image_item_to_result(
|
||||||
_save_debug_image(out_resp.content, 'output.png')
|
event,
|
||||||
except Exception:
|
index=int(getattr(event, "image_index", len(images))),
|
||||||
pass
|
)
|
||||||
|
)
|
||||||
|
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 = (
|
verify_prompt = (
|
||||||
"请对比这两张图片:第一张是原始成衣照片,第二张是套图结果。\n"
|
"请对比这两张图片:第一张是原始成衣照片,第二张是套图结果。"
|
||||||
"验证:1. 花样还原度 2. 裁片覆盖完整度 3. 对齐质量 4. 整体效果。给出评分(1-10)和改进建议。"
|
"请从花样还原度、裁片覆盖完整度、对齐质量、整体效果四个方面评分,并给出改进建议。"
|
||||||
)
|
)
|
||||||
if extra_prompt:
|
if extra_prompt:
|
||||||
verify_prompt += f"\n用户补充:{extra_prompt}"
|
verify_prompt += f"\n用户补充:{extra_prompt}"
|
||||||
|
|
||||||
if is_gemini_model(use_model):
|
return call_vision_messages(
|
||||||
return call_gemini(
|
verify_prompt,
|
||||||
[{"role": "user", "content": "你是服装套图质量检验专家。"}, {"role": "user", "content": verify_prompt}],
|
[garment_b64, canvas_b64],
|
||||||
use_model, images_b64=[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 ====================
|
# ==================== Mock ====================
|
||||||
|
|
||||||
|
|
||||||
def mock_reply(message: str, has_image: bool = False) -> str:
|
def mock_reply(message: str, has_image: bool = False) -> str:
|
||||||
"""未配置 API Key 时的模拟回复"""
|
"""未配置 API Key 时的模拟回复。"""
|
||||||
if has_image:
|
if has_image:
|
||||||
return "【模拟分析】收到图片。AI 分析功能需要配置 AI_API_KEY。"
|
return "【模拟分析】收到图片。请先在 API 配置页填写你自己的转发 Key。"
|
||||||
if "套图" in message:
|
if "套图" in message:
|
||||||
return "套图功能在「参数预设」页面。先选择花样组和裁片组,再添加规则,最后点击生成。"
|
return "套图功能已就绪,但正式 AI 分析前请先在 API 配置页填写你自己的转发 Key。"
|
||||||
return "你好!我是 DesignerCEP AI 助手。你可以问我关于套图、裁片、对齐等功能的问题。"
|
return "你好!我是 DesignerCEP AI 助手。请先在 API 配置页填写你的转发地址和 Key,然后我再帮你执行聊天、看图和套图任务。"
|
||||||
|
|
||||||
|
|
||||||
# ==================== 工具函数 ====================
|
# ==================== 调试工具 ====================
|
||||||
|
|
||||||
|
|
||||||
def _save_debug_image(data: bytes, filename: str):
|
def _save_debug_image(data: bytes, filename: str):
|
||||||
"""保存调试图片到 debug_images 目录"""
|
"""保存调试图片到 debug_images 目录。"""
|
||||||
import os, time
|
import time
|
||||||
debug_dir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'debug_images')
|
|
||||||
|
debug_dir = os.path.join(
|
||||||
|
os.path.dirname(__file__), "..", "..", "..", "debug_images"
|
||||||
|
)
|
||||||
os.makedirs(debug_dir, exist_ok=True)
|
os.makedirs(debug_dir, exist_ok=True)
|
||||||
ts = int(time.time())
|
ts = int(time.time())
|
||||||
path = os.path.join(debug_dir, f'{ts}_{filename}')
|
path = os.path.join(debug_dir, f"{ts}_{filename}")
|
||||||
try:
|
try:
|
||||||
with open(path, 'wb') as f:
|
with open(path, "wb") as file_obj:
|
||||||
f.write(data)
|
file_obj.write(data)
|
||||||
log.info(f"[Debug] 图片已保存: {path}")
|
log.info(f"[Debug] 图片已保存: {path}")
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
log.warning(f"[Debug] 保存失败: {e}")
|
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 typing import List, Optional
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from urllib.parse import urlparse
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
from sqlalchemy import desc
|
from sqlalchemy import desc
|
||||||
|
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
from app.models.logs import PltProcessRecord, UserActionLog, PltPiece
|
from app.models.logs import PltProcessRecord, UserActionLog, PltPiece
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
router = APIRouter()
|
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,
|
record_id=record.id,
|
||||||
group_id=piece_data.group_id,
|
group_id=piece_data.group_id,
|
||||||
size=piece_data.size,
|
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,
|
width_px=piece_data.width_px,
|
||||||
height_px=piece_data.height_px,
|
height_px=piece_data.height_px,
|
||||||
width_cm=piece_data.width_cm,
|
width_cm=piece_data.width_cm,
|
||||||
@@ -235,28 +282,29 @@ async def get_plt_record_detail(
|
|||||||
if not record:
|
if not record:
|
||||||
raise HTTPException(status_code=404, detail="记录不存在")
|
raise HTTPException(status_code=404, detail="记录不存在")
|
||||||
|
|
||||||
return record
|
return normalize_record_piece_urls(record)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/plt/history", response_model=List[PltRecordResponse])
|
@router.get("/plt/history", response_model=List[PltRecordResponse])
|
||||||
async def get_plt_history(
|
async def get_plt_history(
|
||||||
limit: int = Query(20, ge=1, le=100),
|
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),
|
current_username: str = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""获取用户的PLT处理历史(默认保留7天)"""
|
"""获取用户的 PLT 处理历史,days=0 表示全部记录"""
|
||||||
user = db.query(User).filter(User.username == current_username).first()
|
user = db.query(User).filter(User.username == current_username).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
|
||||||
# 只返回指定天数内的记录
|
query = db.query(PltProcessRecord)\
|
||||||
since = datetime.now() - timedelta(days=days)
|
.filter(PltProcessRecord.user_id == user.id)
|
||||||
|
|
||||||
records = db.query(PltProcessRecord)\
|
if days > 0:
|
||||||
.filter(PltProcessRecord.user_id == user.id)\
|
since = datetime.now() - timedelta(days=days)
|
||||||
.filter(PltProcessRecord.created_at >= since)\
|
query = query.filter(PltProcessRecord.created_at >= since)
|
||||||
.order_by(desc(PltProcessRecord.created_at))\
|
|
||||||
|
records = query.order_by(desc(PltProcessRecord.created_at))\
|
||||||
.limit(limit)\
|
.limit(limit)\
|
||||||
.all()
|
.all()
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,25 @@ class UserProfileUpdate(BaseModel):
|
|||||||
nickname: Optional[str] = None
|
nickname: Optional[str] = None
|
||||||
avatar: Optional[str] = None
|
avatar: Optional[str] = None
|
||||||
email: 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,
|
"vip_daily_quota": user.vip_daily_quota,
|
||||||
"total_check_in_days": user.total_check_in_days,
|
"total_check_in_days": user.total_check_in_days,
|
||||||
"consecutive_check_in": user.consecutive_check_in,
|
"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
|
"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
|
user.avatar = data.avatar
|
||||||
if data.email is not None:
|
if data.email is not None:
|
||||||
user.email = data.email
|
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()
|
db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,30 @@
|
|||||||
|
from pathlib import Path
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
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):
|
class Settings(BaseSettings):
|
||||||
ENV: str = "development"
|
ENV: str = "development"
|
||||||
PROJECT_NAME: str = "DesignerCEP Backend"
|
PROJECT_NAME: str = "DesignerCEP Backend"
|
||||||
@@ -25,17 +50,41 @@ class Settings(BaseSettings):
|
|||||||
QINIU_BUCKET: str = ""
|
QINIU_BUCKET: str = ""
|
||||||
QINIU_DOMAIN: str = ""
|
QINIU_DOMAIN: str = ""
|
||||||
|
|
||||||
# AI 配置 — 通义千问 Qwen(DashScope)
|
# AI 配置
|
||||||
AI_API_KEY: str = "" # LLM API Key(OpenAI / DeepSeek 等)
|
AI_PROVIDER: str = "ark"
|
||||||
AI_BASE_URL: str = "" # LLM API 地址(留空则用 OpenAI 默认)
|
AI_API_KEY: str = ""
|
||||||
AI_MODEL: str = "qwen3-max-2026-01-23" # 默认模型(通义千问3深度思考)
|
AI_BASE_URL: str = "https://ark.cn-beijing.volces.com/api/v3"
|
||||||
AI_VISION_MODEL: str = "qwen-vl-max-latest" # 视觉分析模型(看图分析成衣)
|
AI_CHAT_BASE_URL: str = ""
|
||||||
AI_IMAGE_EDIT_MODEL: str = "qwen-image-edit-max-2026-01-16" # 图片编辑/生成模型
|
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 = ""
|
ARK_API_KEY: str = ""
|
||||||
GEMINI_BASE_URL: str = "https://api.apiqik.online"
|
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 = 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 sqlalchemy.orm import sessionmaker, declarative_base
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
# 数据库连接字符串,默认使用 SQLite 本地文件
|
# 数据库连接字符串,SQLite 相对路径已在配置层解析为 Server 目录下的绝对路径
|
||||||
SQLALCHEMY_DATABASE_URL = getattr(settings, "DATABASE_URL", "sqlite:///./designercep.db")
|
SQLALCHEMY_DATABASE_URL = getattr(
|
||||||
|
settings, "DATABASE_URL", "sqlite:///./designercep.db"
|
||||||
|
)
|
||||||
|
|
||||||
# 创建数据库引擎
|
# 创建数据库引擎
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
@@ -81,7 +83,8 @@ def seed_data():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
def ensure_migrations():
|
def ensure_migrations():
|
||||||
# 轻量级迁移:为 SQLite 动态添加缺失列
|
# 轻量级迁移:仅作为本地 SQLite 的兜底补列逻辑。
|
||||||
|
# 正式环境请优先使用 Alembic(见 Server/migrations/README.md)。
|
||||||
if not SQLALCHEMY_DATABASE_URL.startswith("sqlite"):
|
if not SQLALCHEMY_DATABASE_URL.startswith("sqlite"):
|
||||||
return
|
return
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
@@ -152,3 +155,27 @@ def ensure_migrations():
|
|||||||
add_col("users", "consecutive_check_in", "INTEGER DEFAULT 0")
|
add_col("users", "consecutive_check_in", "INTEGER DEFAULT 0")
|
||||||
if not has_column("users", "last_check_in_date"):
|
if not has_column("users", "last_check_in_date"):
|
||||||
add_col("users", "last_check_in_date", "DATE NULL")
|
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.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
from app.core.config import settings
|
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 app.db import init_db
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
app = FastAPI(title=settings.PROJECT_NAME)
|
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
|
# Ensure archives directory exists
|
||||||
os.makedirs("archives", exist_ok=True)
|
os.makedirs(ARCHIVES_DIR, exist_ok=True)
|
||||||
|
|
||||||
# ========== CORS 配置 ==========
|
# ========== CORS 配置 ==========
|
||||||
IS_DEV = os.getenv("ENV", "development") == "development"
|
IS_DEV = settings.ENV == "development"
|
||||||
|
|
||||||
if IS_DEV:
|
if IS_DEV:
|
||||||
# 开发环境:保持宽松
|
# 开发环境:保持宽松
|
||||||
@@ -27,9 +48,9 @@ if IS_DEV:
|
|||||||
)
|
)
|
||||||
else:
|
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}")
|
print(f"🔒 Running in Production Mode: CORS allowed origins: {allowed_origins}")
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=allowed_origins,
|
allow_origins=allowed_origins,
|
||||||
@@ -37,63 +58,93 @@ else:
|
|||||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# ✅ CEP 环境特殊处理 (Origin: null or cep://)
|
# ✅ CEP 环境特殊处理 (Origin: null or cep://)
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def cep_cors_middleware(request: Request, call_next):
|
async def cep_cors_middleware(request: Request, call_next):
|
||||||
origin = request.headers.get("origin")
|
origin = request.headers.get("origin")
|
||||||
|
|
||||||
# CEP 的 Origin 是 null 或 cep://
|
# CEP 的 Origin 是 null 或 cep://
|
||||||
if origin in ["null", None] or (origin and origin.startswith("cep://")):
|
if origin in ["null", None] or (origin and origin.startswith("cep://")):
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
response.headers["Access-Control-Allow-Origin"] = "*"
|
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
response.headers["Access-Control-Allow-Credentials"] = "true"
|
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"] = "*"
|
response.headers["Access-Control-Allow-Headers"] = "*"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
return await call_next(request)
|
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(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(feature.router, prefix=settings.API_V1_STR, tags=["feature"])
|
||||||
app.include_router(checkin.router, prefix=settings.API_V1_STR, tags=["checkin"])
|
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(stats.router, prefix=settings.API_V1_STR, tags=["stats"])
|
||||||
app.include_router(algorithm.router, prefix=settings.API_V1_STR, tags=["algorithm"])
|
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(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_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
|
# Health Check
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health_check():
|
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) ==========
|
# ========== Static Files (Development Only) ==========
|
||||||
if IS_DEV:
|
if IS_DEV:
|
||||||
# Mount archives directory for download
|
# Mount archives directory for download
|
||||||
app.mount("/download", StaticFiles(directory="archives"), name="download")
|
app.mount("/download", StaticFiles(directory=ARCHIVES_DIR), name="download")
|
||||||
else:
|
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("/")
|
@app.get("/")
|
||||||
def read_root():
|
def read_root():
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
if IS_DEV:
|
if IS_DEV:
|
||||||
# 重定向到 Shell 登录页
|
# 重定向到 Shell 登录页
|
||||||
return RedirectResponse(url="/shell/index.html")
|
return RedirectResponse(url="/shell/index.html")
|
||||||
return {"message": "DesignerCEP API is running"}
|
return {"message": "DesignerCEP API is running"}
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def on_startup():
|
def on_startup():
|
||||||
# 应用启动时初始化数据库(创建表)
|
# 应用启动时初始化数据库(创建表)
|
||||||
init_db()
|
init_db()
|
||||||
|
for warning in settings.runtime_warnings():
|
||||||
|
log.warning("[Config] %s", warning)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
|
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)
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
username = Column(String(64), nullable=False, index=True)
|
username = Column(String(64), nullable=False, index=True)
|
||||||
title = Column(String(200), default="新对话") # 对话标题(取首条消息摘要)
|
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())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,17 @@ class User(Base):
|
|||||||
vip_expire = Column(DateTime(timezone=True), nullable=True)
|
vip_expire = Column(DateTime(timezone=True), nullable=True)
|
||||||
vip_daily_quota = Column(Integer, default=0)
|
vip_daily_quota = Column(Integer, default=0)
|
||||||
vip_quota_reset_date = Column(Date, nullable=True)
|
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)
|
total_check_in_days = Column(Integer, default=0)
|
||||||
consecutive_check_in = Column(Integer, default=0)
|
consecutive_check_in = Column(Integer, default=0)
|
||||||
|
|||||||
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
|
qiniu
|
||||||
openai
|
openai
|
||||||
httpx
|
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