feat: expand AI workflow support and refresh docs

This commit is contained in:
2026-03-12 13:47:42 +08:00
parent 8688422578
commit 4ecab597f4
28 changed files with 4806 additions and 1907 deletions

File diff suppressed because it is too large Load Diff

View 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 必须给 colorhex值
- 如果某个裁片在照片中**看不到**(如后片),设置 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}

View File

@@ -1,17 +1,32 @@
# -*- coding: utf-8 -*-
"""
AI 模型调用层
统一管理所有 LLM / Vision / Image 模型的调用逻辑
支持 Qwen (DashScope) 和 Gemini (第三方代理) 两套路由
统一管理所有 AI 模型的调用逻辑
当前默认 provider 为 Ark配置项保持通用命名并支持技能化执行策略
"""
from typing import List
import json, base64, re, logging
from typing import List, Optional
from dataclasses import dataclass
import json
import logging
import os
from app.core.config import settings
from app.api.v1.ai_tools import PS_TOOLS, TOOL_DISPLAY_NAMES
from app.api.v1.ai_skills import AiSkill, get_ai_skill, resolve_ai_skill
log = logging.getLogger(__name__)
@dataclass
class RuntimeAiConfig:
provider: str
api_key: str
base_url: str
chat_base_url: str = ""
vision_base_url: str = ""
image_base_url: str = ""
source: str = "server"
# ==================== Prompts ====================
SYSTEM_PROMPT = """你是 DesignerCEP 的 AI 助手,运行在 Adobe Photoshop CEP 插件中。
@@ -20,395 +35,634 @@ SYSTEM_PROMPT = """你是 DesignerCEP 的 AI 助手,运行在 Adobe Photoshop
1. 回答关于 Photoshop 操作和插件使用的问题
2. 通过工具直接操作 Photoshop创建图层、对齐、查看文档信息等
3. 帮用户排查操作中遇到的错误
4. **AI 智能套图**:两阶段流程先生成预览确认,再提取套到裁片上
4. AI 智能套图:两阶段流程先生成预览确认,再提取套到裁片上
## AI 智能套图流程(两阶段)
## AI 智能套图流程
当用户上传成衣图片并要求套图时:
### 阶段 1 — 识别裁片 + 生成预览(需用户确认)
1. 调用 **identify_pieces** — 截取画布并识别每个图层是什么裁片部位(前片、后片、袖子等)
2. 告诉用户识别结果(如 "M-1=前片, M-2=后片, M-5=左袖..."
3. 调用 **generate_garment_preview** — 会自动在裁片下方标注名称标签(如 "M-1 前片"),然后截取带标签的画布 + 成衣照片一起发给 AI 生成预览
4. 预览图显示在聊天中,每个裁片都有标签,**等用户确认 OK 后**进入阶段 2
### 阶段 2 — 提取花样 + 正式套图(用户确认后)
5. 根据 identify_pieces 分析结果中每个裁片的 type 决定处理方式:
- solid → 设 color 字段PS 直接纯色填充
- fill_pattern → AI 提取花型铺满
- theme_pattern → 底层 PS 纯色填充(设 color+ 上层 AI 提取主题图案(白底+正片叠底)
- mixed_pattern → 底层 AI 提取花型 + 上层 AI 提取主题图案(白底+正片叠底)
6. 调用 **extract_and_apply_all_pieces** 执行套图
7. 可选:调用 **verify_pattern_result** 验证效果
1. 先调用 identify_pieces 识别每个图层是什么裁片部位
2. 告诉用户识别结果
3. 调用 generate_garment_preview 生成带标签的裁片预览图
4. 等用户确认 OK 后,再调用 extract_and_apply_all_pieces 正式套图
重要:
- 阶段 1 完成后必须**等用户"可以"/"OK"**才执行阶段 2
- 用户可能会说"袖子纯色""后幅不要花样"等,要根据 identify_pieces 的结果对应到正确的图层名
重要规则:
- 当用户要求执行 PS 操作时,使用工具完成
- 执行操作前可以先了解当前文档图层状态
- 用简洁的中文回答,适合在小面板中阅读
- 如果工具执行失败,向用户解释原因并建议解决方案
- 预览生成后必须等用户确认,不能自己直接进入正式套图
- 每个工具最多调用 1 次,除非上次执行失败
- 用户要求执行 Photoshop 操作时,优先使用工具完成
- 用户如果是在问“不会怎么做 / 为什么不行 / 下一步怎么操作”,要优先结合当前 Photoshop 上下文答疑,不要只给脱离现场的教程
- 答疑、查询当前信息、执行操作可以在同一轮里完成,不要把它们人为拆成两种模式
- 如果用户的问题和当前文档图层、选区、参考图有关,优先先查 1 个最相关的上下文工具,再给结论
- 如果用户意图已经明确且风险低,可以边解释边执行;如果涉及删除、覆盖、关闭、合并等风险操作,先确认
- 不要只讲原理不动手,也不要只闷头执行不解释;默认输出“判断 + 当前检查/执行结果 + 下一步”
- 用简洁中文回答,适合在小面板中阅读
- 复杂任务先给 2-4 条简短思路,再开始执行
- 默认按 观察 -> 判断 -> 执行 -> 验证 的顺序做事
- 如果任务包含多步骤,不要一上来同时调很多工具,先完成当前阶段再进入下一阶段
- 回答里尽量显式说明“当前阶段”和“下一步”
"""
VISION_PROMPT = """你是一位资深的服装设计分析师,同时也是 DesignerCEP 的 AI 助手。
当用户发送服装/成衣图片时,请从以下维度进行专业分析
1. **服装类别** — 上衣/裤子/裙子/连衣裙/外套/配饰等,细分款式
2. **面料分析** — 根据视觉特征推测面料类型(棉、涤纶、丝绸、针织、牛仔、雪纺等),分析面料质感
3. **颜色与印花** — 主色调、配色方案、印花/图案类型及工艺(数码印花、丝网印刷、提花等)
4. **版型特点** — 修身/宽松/A字/H型等分析领口、袖型、肩线、腰线、下摆处理
5. **工艺细节** — 缝线工艺、拉链/纽扣/暗扣、口袋设计、装饰细节、包边/锁边
6. **设计评价** — 设计亮点、风格定位(休闲/正装/运动/时尚等)、目标消费群体
7. **改进建议** — 如有可改进之处,给出专业建议
请结合图片和用户问题,从服装类别、面料、颜色印花、版型特点、工艺细节、设计评价、改进建议等维度做专业分析
规则:
- 如果图片不是服装相关,也尽力分析图片内容并给出有价值的反馈
- 如果用户同时提了文字问题,请结合图片和问题一起回答
- 用清晰、结构化的中文回答,适合在设计工作中参考
- 回答要专业但不啰嗦,突出重点信息
- 如果图片不是服装相关,也尽力分析图片内容
- 用清晰、结构化的中文回答
- 回答要专业但不啰嗦,突出重点
"""
TOOL_BY_NAME = {
tool["function"]["name"]: tool
for tool in PS_TOOLS
if tool.get("type") == "function" and tool.get("function", {}).get("name")
}
# ==================== 客户端工厂 ====================
def get_runtime_ai_config(user=None) -> RuntimeAiConfig:
user_provider = (getattr(user, "ai_provider", "") or "").strip().lower()
user_api_key = (getattr(user, "ai_api_key", "") or "").strip()
user_base_url = (getattr(user, "ai_base_url", "") or "").strip()
user_chat_base_url = (getattr(user, "ai_chat_base_url", "") or "").strip()
user_vision_base_url = (getattr(user, "ai_vision_base_url", "") or "").strip()
user_image_base_url = (getattr(user, "ai_image_base_url", "") or "").strip()
default_base_url = (
settings.AI_BASE_URL
or settings.ARK_BASE_URL
or "https://ark.cn-beijing.volces.com/api/v3"
)
if user_api_key:
return RuntimeAiConfig(
provider=user_provider or (settings.AI_PROVIDER or "ark").strip().lower(),
api_key=user_api_key,
base_url=user_base_url or default_base_url,
chat_base_url=user_chat_base_url or settings.AI_CHAT_BASE_URL or "",
vision_base_url=user_vision_base_url or settings.AI_VISION_BASE_URL or "",
image_base_url=user_image_base_url or settings.AI_IMAGE_BASE_URL or "",
source="user",
)
return RuntimeAiConfig(
provider=(settings.AI_PROVIDER or "ark").strip().lower(),
api_key=(
settings.AI_API_KEY
or settings.ARK_API_KEY
or os.getenv("AI_API_KEY", "")
or os.getenv("ARK_API_KEY", "")
),
base_url=default_base_url,
chat_base_url=settings.AI_CHAT_BASE_URL or "",
vision_base_url=settings.AI_VISION_BASE_URL or "",
image_base_url=settings.AI_IMAGE_BASE_URL or "",
source="server",
)
def get_ai_provider(runtime_config: Optional[RuntimeAiConfig] = None) -> str:
return (runtime_config.provider if runtime_config else settings.AI_PROVIDER or "ark").strip().lower()
def get_ai_api_key(runtime_config: Optional[RuntimeAiConfig] = None) -> str:
return (runtime_config.api_key if runtime_config else get_runtime_ai_config().api_key).strip()
def get_ai_base_url(
runtime_config: Optional[RuntimeAiConfig] = None, capability: str = "chat"
) -> str:
config = runtime_config or get_runtime_ai_config()
if capability == "vision":
return (config.vision_base_url or config.base_url).strip()
if capability == "image":
return (config.image_base_url or config.base_url).strip()
return (config.chat_base_url or config.base_url).strip()
def has_ai_config(runtime_config: Optional[RuntimeAiConfig] = None) -> bool:
return bool(get_ai_api_key(runtime_config))
def get_ai_client(
runtime_config: Optional[RuntimeAiConfig] = None, capability: str = "chat"
):
"""获取当前 provider 的客户端。"""
from volcenginesdkarkruntime import Ark
provider = get_ai_provider(runtime_config)
if provider != "ark":
raise ValueError(f"当前暂不支持的 AI_PROVIDER: {provider}")
api_key = get_ai_api_key(runtime_config)
if not api_key:
raise ValueError("AI_API_KEY 未配置")
return Ark(
base_url=get_ai_base_url(runtime_config, capability=capability),
api_key=api_key,
)
def is_gemini_model(model_name: str) -> bool:
"""兼容旧代码,当前已统一走豆包。"""
return False
def _tool_schemas_for_skill(skill: AiSkill) -> List[dict]:
if not skill.allowed_tools:
return []
return [TOOL_BY_NAME[name] for name in skill.allowed_tools if name in TOOL_BY_NAME]
def _numbered_block(items: tuple[str, ...]) -> str:
if not items:
return ""
return "\n".join(f"{idx + 1}. {item}" for idx, item in enumerate(items))
def _bullet_block(items: tuple[str, ...]) -> str:
if not items:
return ""
return "\n".join(f"- {item}" for item in items)
def _build_skill_prompt(skill: AiSkill, has_image: bool = False) -> str:
workflow = _numbered_block(skill.workflow)
guardrails = _bullet_block(skill.guardrails)
success_criteria = _bullet_block(skill.success_criteria)
triage_questions = _numbered_block(skill.triage_questions)
deliverables = _bullet_block(skill.deliverables)
execution_notes = _bullet_block(skill.execution_notes)
tool_names = (
"".join(TOOL_DISPLAY_NAMES.get(name, name) for name in skill.allowed_tools)
if skill.allowed_tools
else "当前技能以分析和建议为主,不主动调用 Photoshop 工具。"
)
origin = skill.origin or "项目内置技能"
origin_url = skill.origin_url or ""
upstream_skill = skill.upstream_skill or ""
return f"""当前启用技能:{skill.name}{skill.id}
技能定位:{skill.description}
执行模式:{skill.mode}
规划风格:{skill.planning_style}
图片提示:{skill.image_hint or ''}
来源:{origin}
上游技能:{upstream_skill}
来源地址:{origin_url}
推荐工作流:
{workflow}
预判问题:
{triage_questions}
期望交付:
{deliverables}
执行边界:
{guardrails}
执行备注:
{execution_notes}
成功标准:
{success_criteria}
本技能优先工具:
{tool_names}
本轮补充要求:
- 回复保持中文、简洁、能落地。
- 涉及具体执行时,先根据当前技能给出简短思路,再调用工具。
- 根据用户问题动态混合答疑、查询与执行;如果用户是在求助“怎么做/为什么失败/下一步怎么弄”,不要把答疑和执行拆成两轮。
- 能读取当前 Photoshop 上下文时,优先读当前信息后再回答;不要只给泛化教程。
- 优先先想清楚计划,再动手执行;如果任务复杂,先向用户同步 2 到 4 条阶段计划。
- 每轮优先调用最相关的 1 个工具,避免一口气乱调很多工具。
- 如果图片信息不足、文档上下文不足或风险较高,要先说明再操作。
- 每完成一个阶段,要用一句中文总结已完成内容,并指向下一步。
- {'本轮带有图片,请结合图片内容和技能策略做判断。' if has_image else '本轮没有新图片,优先结合聊天历史和 Photoshop 上下文。'}
"""
# ==================== 路由判断 ====================
def is_gemini_model(model_name: str) -> bool:
"""判断是否是 Gemini 模型"""
return bool(model_name) and "gemini" in model_name.lower()
def _chat_system_prompt(skill: AiSkill, has_image: bool = False) -> str:
return f"{SYSTEM_PROMPT}\n\n{_build_skill_prompt(skill, has_image)}"
# ==================== Qwen / OpenAI 兼容调用 ====================
def _vision_instructions(skill: AiSkill) -> str:
return f"{VISION_PROMPT}\n\n{_build_skill_prompt(skill, has_image=True)}"
def call_llm_with_tools(messages_history: List[dict], model_override: str = None):
"""调用 LLM支持 function calling"""
from openai import OpenAI
def _messages_with_system(
messages_history: List[dict], skill: AiSkill, has_image: bool = False
) -> List[dict]:
return [{"role": "system", "content": _chat_system_prompt(skill, has_image)}] + messages_history
def _extract_response_text(response) -> str:
texts: List[str] = []
for output_item in getattr(response, "output", []) or []:
for content_item in getattr(output_item, "content", []) or []:
if getattr(content_item, "type", "") == "output_text":
text_value = getattr(content_item, "text", "")
if text_value:
texts.append(text_value)
return "\n".join(texts).strip()
def _image_data_url(image_base64: str) -> str:
return f"data:image/jpeg;base64,{image_base64}"
# ==================== 聊天 / 工具调用 ====================
def call_llm_with_tools(
messages_history: List[dict],
model_override: str = None,
skill_id: Optional[str] = None,
has_image: bool = False,
runtime_config: Optional[RuntimeAiConfig] = None,
):
"""调用当前配置的对话模型,支持 function calling。"""
use_model = model_override or settings.AI_MODEL
client = OpenAI(
api_key=settings.AI_API_KEY,
base_url=settings.AI_BASE_URL or "https://api.openai.com/v1",
client = get_ai_client(runtime_config, capability="chat")
skill = get_ai_skill(skill_id) or resolve_ai_skill(
message=str(messages_history[-1].get("content", "")) if messages_history else "",
history_messages=messages_history[:-1],
requested_skill_id=skill_id,
has_image=has_image,
)
messages = _messages_with_system(messages_history, skill, has_image)
tools = _tool_schemas_for_skill(skill)
messages = [{"role": "system", "content": SYSTEM_PROMPT}] + messages_history
log.info(f"{'=' * 60}")
log.info(f"[LLM] provider={get_ai_provider(runtime_config)} model={use_model} source={(runtime_config.source if runtime_config else 'server')}")
log.info(f"[LLM] skill={skill.id}")
log.info(f"[LLM] 消息数量: {len(messages)}")
log.info(f"[LLM] 工具数量: {len(tools)}")
log.info(f"{'='*60}")
log.info(f"[LLM] 调用工具模型: {use_model}{' (override)' if model_override else ''}")
log.info(f"[LLM] 消息数量: {len(messages)} (system + {len(messages_history)} history)")
for i, m in enumerate(messages_history[-5:]):
role = m['role']
content = m['content'][:120] if m.get('content') else '(empty)'
log.info(f"[LLM] history[-{len(messages_history)-i}] {role}: {content}")
log.info(f"[LLM] 工具数量: {len(PS_TOOLS)}")
request_kwargs = {
"model": use_model,
"messages": messages,
"temperature": 0.3,
}
if tools:
request_kwargs.update(
{
"tools": tools,
"tool_choice": "auto",
"parallel_tool_calls": False,
}
)
completion = client.chat.completions.create(
model=use_model,
messages=messages,
tools=PS_TOOLS,
tool_choice="auto",
)
completion = client.chat.completions.create(**request_kwargs)
choice = completion.choices[0]
message = choice.message
tool_calls = getattr(message, "tool_calls", None) or []
log.info(f"[LLM] 响应 finish_reason={choice.finish_reason}")
if message.content:
log.info(f"[LLM] 回复文本: {message.content[:150]}...")
if message.tool_calls:
for tc in message.tool_calls:
log.info(f"[LLM] 工具调用: {tc.function.name}({tc.function.arguments[:200]})")
else:
log.info(f"[LLM] 无工具调用")
log.info(f"{'='*60}")
if message.tool_calls and len(message.tool_calls) > 0:
if tool_calls:
tool_calls_data = []
for tc in message.tool_calls:
for tc in tool_calls:
args = {}
if tc.function.arguments:
function = getattr(tc, "function", None)
raw_args = getattr(function, "arguments", "") if function else ""
if raw_args:
try:
args = json.loads(tc.function.arguments)
args = json.loads(raw_args)
except json.JSONDecodeError:
args = {}
tool_calls_data.append({
"id": tc.id,
"name": tc.function.name,
"display_name": TOOL_DISPLAY_NAMES.get(tc.function.name, tc.function.name),
"args": args,
"status": "pending"
})
return message.content or "", tool_calls_data
function_name = getattr(function, "name", "") if function else ""
tool_calls_data.append(
{
"id": getattr(tc, "id", function_name),
"name": function_name,
"display_name": TOOL_DISPLAY_NAMES.get(function_name, function_name),
"args": args,
"status": "pending",
}
)
return getattr(message, "content", "") or "", tool_calls_data, skill.to_public_dict()
return message.content or "", None
return getattr(message, "content", "") or "", None, skill.to_public_dict()
# ==================== Gemini 调用OpenAI 兼容代理) ====================
def call_gemini(messages_history: List[dict], model: str, images_b64: List[str] = None) -> str:
"""调用 Gemini通过第三方代理OpenAI 兼容格式)"""
from openai import OpenAI as _OpenAI
if not settings.GEMINI_API_KEY or not settings.GEMINI_BASE_URL:
raise ValueError("GEMINI_API_KEY 或 GEMINI_BASE_URL 未配置")
client = _OpenAI(
api_key=settings.GEMINI_API_KEY,
base_url=f"{settings.GEMINI_BASE_URL}/v1",
)
messages = []
def call_gemini(messages_history: List[dict], model: str, images_b64: List[str] = None):
"""兼容旧接口,内部统一转到当前视觉模型。"""
prompt = ""
for msg in messages_history:
role = msg.get("role", "user")
content = msg.get("content", "")
if role not in ("system", "user", "assistant"):
role = "user"
messages.append({"role": role, "content": content})
if msg.get("role") == "user" and msg.get("content"):
prompt = str(msg["content"])
if images_b64:
last_user_idx = None
for i in range(len(messages) - 1, -1, -1):
if messages[i]["role"] == "user":
last_user_idx = i
break
if last_user_idx is not None:
text_content = messages[last_user_idx]["content"]
multimodal_content = []
for img_b64 in images_b64:
multimodal_content.append({
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}
})
multimodal_content.append({"type": "text", "text": text_content})
messages[last_user_idx]["content"] = multimodal_content
return call_vision_messages(prompt or "请分析这张图片。", images_b64, model)
log.info(f"{'='*60}")
log.info(f"[Gemini] 调用模型: {model} (OpenAI 兼容)")
log.info(f"[Gemini] 消息数: {len(messages)}, 图片数: {len(images_b64) if images_b64 else 0}")
completion = client.chat.completions.create(model=model, messages=messages)
result = completion.choices[0].message.content or ""
log.info(f"[Gemini] 回复: {result[:200]}...")
return result
reply, _, _ = call_llm_with_tools(messages_history, model_override=model)
return reply
def call_gemini_with_tools(messages_history: List[dict], model: str) -> tuple:
"""用 Gemini 做对话(不支持 function calling"""
full_history = [{"role": "system", "content": SYSTEM_PROMPT}] + messages_history
log.info(f"{'='*60}")
log.info(f"[Gemini] 调用对话模型: {model}")
for i, m in enumerate(messages_history[-3:]):
log.info(f"[Gemini] history[-{len(messages_history)-i}] {m['role']}: {str(m.get('content',''))[:120]}")
result = call_gemini(full_history, model)
log.info(f"[Gemini] 回复文本: {result[:150]}...")
return result, None
"""兼容旧接口,内部统一转到当前工具调用。"""
return call_llm_with_tools(messages_history, model_override=model)
# ==================== 视觉模型 ====================
def call_vision_llm(user_message: str, image_base64: str, history: List[dict], model_override: str = None) -> str:
"""调用视觉模型分析图片(自动路由 Qwen / Gemini"""
def call_vision_messages(
user_message: str,
images_b64: List[str],
model_override: str = None,
instructions_override: Optional[str] = None,
runtime_config: Optional[RuntimeAiConfig] = None,
) -> str:
"""调用当前视觉模型分析图片。"""
use_model = model_override or settings.AI_VISION_MODEL
client = get_ai_client(runtime_config, capability="vision")
if is_gemini_model(use_model):
log.info(f"[Vision] 使用 Gemini 视觉模型: {use_model}")
msgs = [{"role": "system", "content": VISION_PROMPT}]
for h in history[-10:]:
role = h["role"] if h["role"] != "tool" else "user"
msgs.append({"role": role, "content": h["content"]})
msgs.append({"role": "user", "content": user_message})
return call_gemini(msgs, use_model, images_b64=[image_base64])
content = []
for image_b64 in images_b64:
content.append({"type": "input_image", "image_url": _image_data_url(image_b64)})
content.append({"type": "input_text", "text": user_message})
from openai import OpenAI
client = OpenAI(api_key=settings.AI_API_KEY, base_url=settings.AI_BASE_URL or "https://api.openai.com/v1")
response = client.responses.create(
model=use_model,
instructions=instructions_override or VISION_PROMPT,
input=[{"role": "user", "content": content}],
)
user_content = [
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_base64}"}},
{"type": "text", "text": user_message}
]
messages = [{"role": "system", "content": VISION_PROMPT}]
for h in history[-10:]:
role = h["role"] if h["role"] != "tool" else "user"
messages.append({"role": role, "content": h["content"]})
messages.append({"role": "user", "content": user_content})
completion = client.chat.completions.create(model=use_model, messages=messages)
return completion.choices[0].message.content or ""
result = _extract_response_text(response)
log.info(
f"[Vision] provider={get_ai_provider(runtime_config)} model={use_model} 输出长度: {len(result)}"
)
return result
# ==================== 图片编辑/生成模型 ====================
def call_vision_llm(
user_message: str,
image_base64: str,
history: List[dict],
model_override: str = None,
skill_id: Optional[str] = None,
runtime_config: Optional[RuntimeAiConfig] = None,
) -> str:
"""兼容旧接口,调用当前视觉模型。"""
skill = get_ai_skill(skill_id) or resolve_ai_skill(
message=user_message,
history_messages=history,
requested_skill_id=skill_id,
has_image=True,
)
combined_message = user_message
if history:
recent_text = "\n".join(
str(item.get("content", "")) for item in history[-5:] if item.get("content")
)
if recent_text:
combined_message = f"历史对话:\n{recent_text}\n\n当前问题:{user_message}"
return call_vision_messages(
combined_message,
[image_base64],
model_override,
instructions_override=_vision_instructions(skill),
runtime_config=runtime_config,
)
def call_image_model(images_b64: List[str], prompt: str, model_override: str = None) -> tuple:
"""调用图片编辑/生成模型(自动路由 DashScope / Gemini返回 (url_or_datauri, description)"""
import requests as http_requests
# ==================== 图片编辑 / 生成 ====================
def call_image_model(
images_b64: List[str],
prompt: str,
model_override: str = None,
runtime_config: Optional[RuntimeAiConfig] = None,
) -> tuple:
"""调用当前图片模型,返回 (url_or_datauri, description)。"""
result = call_image_model_batch(
images_b64=images_b64,
prompt=prompt,
model_override=model_override,
size="2K",
max_images=1,
stream=False,
runtime_config=runtime_config,
)
first_image = result["images"][0]
return str(first_image["url"]), ""
def _image_response_error_message(payload) -> str:
error = getattr(payload, "error", None)
return str(getattr(error, "message", "") or "").strip()
def _image_usage_dict(payload) -> Optional[dict]:
usage = getattr(payload, "usage", None)
if usage is None:
return None
if hasattr(usage, "model_dump"):
return usage.model_dump(mode="json")
if isinstance(usage, dict):
return usage
return None
def _image_item_to_result(image_item, index: int) -> dict:
image_url = getattr(image_item, "url", "") or ""
image_b64 = getattr(image_item, "b64_json", "") or ""
image_size = getattr(image_item, "size", "") or ""
if image_url:
return {"index": index, "url": image_url, "size": image_size}
if image_b64:
padding = (-len(image_b64)) % 4
if padding:
image_b64 += "=" * padding
return {
"index": index,
"url": f"data:image/png;base64,{image_b64}",
"size": image_size,
}
raise ValueError("图片模型返回了空图片结果")
def call_image_model_batch(
images_b64: List[str],
prompt: str,
model_override: str = None,
size: str = "2K",
max_images: int = 1,
stream: Optional[bool] = None,
runtime_config: Optional[RuntimeAiConfig] = None,
) -> dict:
"""调用当前图片模型,支持单图、参考图生图和连续组图。"""
from volcenginesdkarkruntime.types.images.images import (
SequentialImageGenerationOptions,
)
use_model = model_override or settings.AI_IMAGE_EDIT_MODEL
client = get_ai_client(runtime_config, capability="image")
image_count = max(1, min(int(max_images or 1), 4))
should_stream = (image_count > 1) if stream is None else bool(stream)
# ---------- GeminiOpenAI 兼容代理) ----------
if is_gemini_model(use_model):
log.info(f"[ImageModel] 使用 Gemini 图片模型: {use_model}")
if not settings.GEMINI_API_KEY or not settings.GEMINI_BASE_URL:
raise ValueError("GEMINI_API_KEY 或 GEMINI_BASE_URL 未配置")
from openai import OpenAI as _OpenAI
client = _OpenAI(api_key=settings.GEMINI_API_KEY, base_url=f"{settings.GEMINI_BASE_URL}/v1")
content_parts = [{"type": "text", "text": prompt}]
for img_b64 in images_b64:
content_parts.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}})
log.info(f"[ImageModel] Gemini OpenAI 兼容, 模型: {use_model}")
log.info(f"[ImageModel] 图片数: {len(images_b64)}, 各图: {[len(b)//1024 for b in images_b64]}KB")
log.info(f"[ImageModel] 提示词: {prompt[:200]}")
completion = client.chat.completions.create(model=use_model, messages=[{"role": "user", "content": content_parts}])
result_content = completion.choices[0].message.content or ""
log.info(f"[ImageModel] Gemini 回复长度: {len(result_content)} chars")
# 提取 base64 图片
match = re.search(r'!\[.*?\]\((data:image/(\w+);base64,([^)]+))\)', result_content)
if not match:
match = re.search(r'(data:image/(\w+);base64,([A-Za-z0-9+/=]+))', result_content)
if not match:
log.warning(f"[ImageModel] Gemini 响应中无图片前500字: {result_content[:500]}")
raise ValueError("Gemini 未返回图片,请检查模型是否支持图片生成")
img_format = match.group(2)
image_b64 = match.group(3)
padding = 4 - len(image_b64) % 4
if padding != 4:
image_b64 += '=' * padding
log.info(f"[ImageModel] Gemini 返回图片: image/{img_format}, {len(image_b64)//1024}KB")
_save_debug_image(base64.b64decode(image_b64), f'gemini_output.{img_format}')
description = re.sub(r'!\[.*?\]\(data:image/[^)]+\)', '', result_content).strip()
return f"data:image/{img_format};base64,{image_b64}", description
# ---------- DashScope 原生接口 ----------
api_url = "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
content_parts = []
for img_b64 in images_b64:
content_parts.append({"image": f"data:image/jpeg;base64,{img_b64}"})
content_parts.append({"text": prompt})
payload = {
"model": use_model,
"input": {"messages": [{"role": "user", "content": content_parts}]},
"parameters": {"n": 1, "watermark": False, "prompt_extend": True}
}
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {settings.AI_API_KEY}"}
log.info(f"{'='*60}")
log.info(f"[ImageModel] DashScope 原生 API, 模型: {use_model}")
log.info(f"[ImageModel] 图片数: {len(images_b64)}, 各图: {[len(b)//1024 for b in images_b64]}KB")
image_inputs = [_image_data_url(img_b64) for img_b64 in images_b64] or None
image_payload = None
if image_inputs:
image_payload = image_inputs[0] if len(image_inputs) == 1 else image_inputs
log.info(f"{'=' * 60}")
log.info(f"[ImageModel] provider={get_ai_provider(runtime_config)} model={use_model} source={(runtime_config.source if runtime_config else 'server')}")
log.info(f"[ImageModel] 图片数: {len(images_b64)}")
log.info(f"[ImageModel] 目标生成张数: {image_count}")
log.info(f"[ImageModel] 提示词: {prompt[:200]}")
for idx, img_b64 in enumerate(images_b64):
_save_debug_image(base64.b64decode(img_b64), f'input_{idx}.jpg')
request_kwargs = {
"model": use_model,
"prompt": prompt,
"image": image_payload,
"response_format": "url",
"size": size or "2K",
"watermark": True,
"stream": should_stream,
"sequential_image_generation": "auto" if image_count > 1 else "disabled",
}
if image_payload is None:
request_kwargs.pop("image")
if image_count > 1:
request_kwargs["sequential_image_generation_options"] = (
SequentialImageGenerationOptions(max_images=image_count)
)
resp = http_requests.post(api_url, json=payload, headers=headers, timeout=120)
if resp.status_code != 200:
error_data = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
err_msg = error_data.get("message", resp.text[:300])
log.error(f"[ImageModel] API 错误: {resp.status_code} {err_msg}")
if "data_inspection_failed" in str(error_data):
raise ValueError("图片内容未通过安全审核,请更换图片")
raise ValueError(f"图片模型调用失败({resp.status_code}): {err_msg}")
if should_stream:
stream_response = client.images.generate(**request_kwargs)
images: List[dict] = []
usage: Optional[dict] = None
errors: List[str] = []
data = resp.json()
output = data.get("output", {})
choices = output.get("choices", [])
if not choices:
raise ValueError("模型未返回结果")
for event in stream_response:
if event is None:
continue
content_list = choices[0].get("message", {}).get("content", [])
image_url = None
description = ""
for item in content_list:
if isinstance(item, dict):
if "image" in item:
image_url = item["image"]
elif "text" in item:
description += item["text"]
event_type = str(getattr(event, "type", "") or "")
event_error = _image_response_error_message(event)
if not image_url:
raise ValueError("模型未返回图片")
if event_type.endswith("partial_failed"):
if event_error:
errors.append(event_error)
continue
log.info(f"[ImageModel] 输出图片 URL: {image_url[:120]}...")
try:
out_resp = http_requests.get(image_url, timeout=60)
if out_resp.status_code == 200:
_save_debug_image(out_resp.content, 'output.png')
except Exception:
pass
if event_type.endswith("partial_succeeded"):
try:
images.append(
_image_item_to_result(
event,
index=int(getattr(event, "image_index", len(images))),
)
)
except Exception as exc:
errors.append(str(exc))
continue
return image_url, description
if event_type.endswith("completed"):
usage = _image_usage_dict(event)
if errors and not images:
raise ValueError("".join(errors))
if not images:
raise ValueError("图片模型未返回结果")
images.sort(key=lambda item: int(item.get("index", 0)))
return {"images": images, "usage": usage, "errors": errors}
response_kwargs = {
"model": use_model,
"prompt": prompt,
"image": image_payload,
"response_format": "url",
"size": size or "2K",
"stream": False,
"watermark": True,
"sequential_image_generation": "auto" if image_count > 1 else "disabled",
"sequential_image_generation_options": (
SequentialImageGenerationOptions(max_images=image_count)
if image_count > 1
else None
),
}
if image_payload is None:
response_kwargs.pop("image")
response = client.images.generate(**response_kwargs)
error_message = _image_response_error_message(response)
if error_message:
raise ValueError(error_message)
data = getattr(response, "data", None) or []
if not data:
raise ValueError("图片模型未返回结果")
images = [_image_item_to_result(item, index=idx) for idx, item in enumerate(data)]
return {"images": images, "usage": _image_usage_dict(response), "errors": []}
# ==================== 验证套图效果 ====================
def verify_pattern_result(garment_b64: str, canvas_b64: str, extra_prompt: str = None, vision_model: str = None) -> str:
"""用视觉模型对比原始成衣和套图结果"""
use_model = vision_model or settings.AI_VISION_MODEL
log.info(f"[Verify] 模型: {use_model}, 成衣: {len(garment_b64)//1024}KB, 画布: {len(canvas_b64)//1024}KB")
def verify_pattern_result(
garment_b64: str,
canvas_b64: str,
extra_prompt: str = None,
vision_model: str = None,
runtime_config: Optional[RuntimeAiConfig] = None,
) -> str:
"""用当前视觉模型对比原始成衣和套图结果。"""
verify_prompt = (
"请对比这两张图片:第一张是原始成衣照片,第二张是套图结果。\n"
"验证1. 花样还原度 2. 裁片覆盖完整度 3. 对齐质量 4. 整体效果。给出评分(1-10)和改进建议。"
"请对比这两张图片:第一张是原始成衣照片,第二张是套图结果。"
"请从花样还原度裁片覆盖完整度、对齐质量、整体效果四个方面评分,并给出改进建议。"
)
if extra_prompt:
verify_prompt += f"\n用户补充:{extra_prompt}"
if is_gemini_model(use_model):
return call_gemini(
[{"role": "user", "content": "你是服装套图质量检验专家。"}, {"role": "user", "content": verify_prompt}],
use_model, images_b64=[garment_b64, canvas_b64]
)
from openai import OpenAI
client = OpenAI(api_key=settings.AI_API_KEY, base_url=settings.AI_BASE_URL or "https://api.openai.com/v1")
completion = client.chat.completions.create(
model=use_model,
messages=[
{"role": "system", "content": "你是服装套图质量检验专家。"},
{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{garment_b64}"}},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{canvas_b64}"}},
{"type": "text", "text": verify_prompt},
]},
],
return call_vision_messages(
verify_prompt,
[garment_b64, canvas_b64],
model_override=vision_model or settings.AI_VISION_MODEL,
runtime_config=runtime_config,
)
return completion.choices[0].message.content or ""
# ==================== Mock ====================
def mock_reply(message: str, has_image: bool = False) -> str:
"""未配置 API Key 时的模拟回复"""
"""未配置 API Key 时的模拟回复"""
if has_image:
return "【模拟分析】收到图片。AI 分析功能需要配置 AI_API_KEY"
return "【模拟分析】收到图片。请先在 API 配置页填写你自己的转发 Key"
if "套图" in message:
return "套图功能在「参数预设」页面。先选择花样组和裁片组,再添加规则,最后点击生成"
return "你好!我是 DesignerCEP AI 助手。你可以问我关于套图、裁片、对齐等功能的问题"
return "套图功能已就绪,但正式 AI 分析前请先在 API 配置页填写你自己的转发 Key"
return "你好!我是 DesignerCEP AI 助手。请先在 API 配置页填写你的转发地址和 Key然后我再帮你执行聊天、看图和套图任务"
# ==================== 工具函数 ====================
# ==================== 调试工具 ====================
def _save_debug_image(data: bytes, filename: str):
"""保存调试图片到 debug_images 目录"""
import os, time
debug_dir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'debug_images')
"""保存调试图片到 debug_images 目录"""
import time
debug_dir = os.path.join(
os.path.dirname(__file__), "..", "..", "..", "debug_images"
)
os.makedirs(debug_dir, exist_ok=True)
ts = int(time.time())
path = os.path.join(debug_dir, f'{ts}_{filename}')
path = os.path.join(debug_dir, f"{ts}_{filename}")
try:
with open(path, 'wb') as f:
f.write(data)
with open(path, "wb") as file_obj:
file_obj.write(data)
log.info(f"[Debug] 图片已保存: {path}")
except Exception as e:
log.warning(f"[Debug] 保存失败: {e}")
except Exception as exc:
log.warning(f"[Debug] 保存失败: {exc}")

View 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()

View 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

View File

@@ -5,18 +5,65 @@
from typing import List, Optional
from datetime import datetime, timedelta
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import desc
from app.db import get_db
from app.core.config import settings
from app.core.security import get_current_user
from app.models.logs import PltProcessRecord, UserActionLog, PltPiece
from app.models.user import User
router = APIRouter()
CURRENT_QINIU_DOMAIN = settings.QINIU_DOMAIN.rstrip("/")
CURRENT_QINIU_HOST = urlparse(CURRENT_QINIU_DOMAIN).netloc.lower() if CURRENT_QINIU_DOMAIN else ""
KNOWN_QINIU_HOST_MARKERS = ("qiniuio.com", "clouddn.com", "qnssl.com")
LEGACY_QINIU_HOSTS = {
"iovip-z2.qiniuio.com",
"t9zfnhhrn.hn-bkt.clouddn.com",
}
def normalize_qiniu_url(url: Optional[str]) -> Optional[str]:
"""将历史记录中的旧七牛域名切换到当前绑定域名。"""
if not url or not CURRENT_QINIU_DOMAIN:
return url
parsed = urlparse(url)
host = parsed.netloc.lower()
if not parsed.scheme or not host or not parsed.path:
return url
if host == CURRENT_QINIU_HOST:
return url
is_qiniu_host = host in LEGACY_QINIU_HOSTS or any(
host.endswith(marker) for marker in KNOWN_QINIU_HOST_MARKERS
)
if not is_qiniu_host:
return url
suffix = parsed.path
if parsed.query:
suffix = f"{suffix}?{parsed.query}"
return f"{CURRENT_QINIU_DOMAIN}{suffix}"
def normalize_record_piece_urls(record: Optional[PltProcessRecord]) -> Optional[PltProcessRecord]:
"""在返回详情前修正裁片图片地址,兼容旧域名历史记录。"""
if not record or not record.pieces:
return record
for piece in record.pieces:
piece.image_url = normalize_qiniu_url(piece.image_url)
return record
# ==================== 数据模型 ====================
@@ -197,7 +244,7 @@ async def create_plt_record(
record_id=record.id,
group_id=piece_data.group_id,
size=piece_data.size,
image_url=piece_data.image_url,
image_url=normalize_qiniu_url(piece_data.image_url),
width_px=piece_data.width_px,
height_px=piece_data.height_px,
width_cm=piece_data.width_cm,
@@ -235,28 +282,29 @@ async def get_plt_record_detail(
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
return record
return normalize_record_piece_urls(record)
@router.get("/plt/history", response_model=List[PltRecordResponse])
async def get_plt_history(
limit: int = Query(20, ge=1, le=100),
days: int = Query(7, ge=1, le=30), # 保留天数默认7天
days: int = Query(0, ge=0, le=3650), # 0 表示不限制时间
current_username: str = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取用户的PLT处理历史默认保留7天"""
"""获取用户的 PLT 处理历史days=0 表示全部记录"""
user = db.query(User).filter(User.username == current_username).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 只返回指定天数内的记录
since = datetime.now() - timedelta(days=days)
records = db.query(PltProcessRecord)\
.filter(PltProcessRecord.user_id == user.id)\
.filter(PltProcessRecord.created_at >= since)\
.order_by(desc(PltProcessRecord.created_at))\
query = db.query(PltProcessRecord)\
.filter(PltProcessRecord.user_id == user.id)
if days > 0:
since = datetime.now() - timedelta(days=days)
query = query.filter(PltProcessRecord.created_at >= since)
records = query.order_by(desc(PltProcessRecord.created_at))\
.limit(limit)\
.all()

View File

@@ -21,6 +21,25 @@ class UserProfileUpdate(BaseModel):
nickname: Optional[str] = None
avatar: Optional[str] = None
email: Optional[str] = None
ai_provider: Optional[str] = None
ai_base_url: Optional[str] = None
ai_chat_base_url: Optional[str] = None
ai_vision_base_url: Optional[str] = None
ai_image_base_url: Optional[str] = None
ai_api_key: Optional[str] = None
ai_model: Optional[str] = None
ai_vision_model: Optional[str] = None
ai_image_model: Optional[str] = None
clear_ai_api_key: bool = False
def mask_api_key(value: Optional[str]) -> Optional[str]:
if not value:
return None
raw = value.strip()
if len(raw) <= 8:
return "*" * len(raw)
return f"{raw[:4]}{'*' * (len(raw) - 8)}{raw[-4:]}"
# ==================== 用户资料管理 ====================
@@ -43,6 +62,16 @@ async def get_user_profile(db: Session = Depends(get_db), current_username: str
"vip_daily_quota": user.vip_daily_quota,
"total_check_in_days": user.total_check_in_days,
"consecutive_check_in": user.consecutive_check_in,
"ai_provider": user.ai_provider or "ark",
"ai_base_url": user.ai_base_url or "",
"ai_chat_base_url": user.ai_chat_base_url or "",
"ai_vision_base_url": user.ai_vision_base_url or "",
"ai_image_base_url": user.ai_image_base_url or "",
"ai_model": user.ai_model or "",
"ai_vision_model": user.ai_vision_model or "",
"ai_image_model": user.ai_image_model or "",
"has_ai_api_key": bool(user.ai_api_key),
"ai_api_key_masked": mask_api_key(user.ai_api_key),
"created_at": user.created_at.isoformat() if user.created_at else None
}
@@ -64,6 +93,28 @@ async def update_user_profile(data: UserProfileUpdate, db: Session = Depends(get
user.avatar = data.avatar
if data.email is not None:
user.email = data.email
if data.ai_provider is not None:
user.ai_provider = data.ai_provider.strip().lower() or None
if data.ai_base_url is not None:
user.ai_base_url = data.ai_base_url.strip() or None
if data.ai_chat_base_url is not None:
user.ai_chat_base_url = data.ai_chat_base_url.strip() or None
if data.ai_vision_base_url is not None:
user.ai_vision_base_url = data.ai_vision_base_url.strip() or None
if data.ai_image_base_url is not None:
user.ai_image_base_url = data.ai_image_base_url.strip() or None
if data.ai_model is not None:
user.ai_model = data.ai_model.strip() or None
if data.ai_vision_model is not None:
user.ai_vision_model = data.ai_vision_model.strip() or None
if data.ai_image_model is not None:
user.ai_image_model = data.ai_image_model.strip() or None
if data.clear_ai_api_key:
user.ai_api_key = None
elif data.ai_api_key is not None:
trimmed_key = data.ai_api_key.strip()
if trimmed_key:
user.ai_api_key = trimmed_key
db.commit()

View File

@@ -1,5 +1,30 @@
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
BASE_DIR = Path(__file__).resolve().parents[2]
ENV_FILE = BASE_DIR / ".env"
PLACEHOLDER_SECRET_VALUES = {
"",
"your-super-secret-key-change-this",
"admin-secret-token-change-this",
}
def resolve_database_url(raw_url: str) -> str:
"""Resolve relative SQLite paths against the Server directory."""
if not raw_url.startswith("sqlite:///"):
return raw_url
sqlite_path = raw_url.removeprefix("sqlite:///")
if not sqlite_path or sqlite_path == ":memory:":
return raw_url
path_obj = Path(sqlite_path)
if path_obj.is_absolute():
return raw_url
return f"sqlite:///{(BASE_DIR / path_obj).resolve().as_posix()}"
class Settings(BaseSettings):
ENV: str = "development"
PROJECT_NAME: str = "DesignerCEP Backend"
@@ -25,17 +50,41 @@ class Settings(BaseSettings):
QINIU_BUCKET: str = ""
QINIU_DOMAIN: str = ""
# AI 配置 — 通义千问 QwenDashScope
AI_API_KEY: str = "" # LLM API KeyOpenAI / DeepSeek 等)
AI_BASE_URL: str = "" # LLM API 地址(留空则用 OpenAI 默认)
AI_MODEL: str = "qwen3-max-2026-01-23" # 默认模型通义千问3深度思考
AI_VISION_MODEL: str = "qwen-vl-max-latest" # 视觉分析模型(看图分析成衣)
AI_IMAGE_EDIT_MODEL: str = "qwen-image-edit-max-2026-01-16" # 图片编辑/生成模型
# AI 配置
AI_PROVIDER: str = "ark"
AI_API_KEY: str = ""
AI_BASE_URL: str = "https://ark.cn-beijing.volces.com/api/v3"
AI_CHAT_BASE_URL: str = ""
AI_VISION_BASE_URL: str = ""
AI_IMAGE_BASE_URL: str = ""
AI_MODEL: str = "doubao-seed-2-0-mini-260215"
AI_VISION_MODEL: str = "doubao-seed-2-0-mini-260215"
AI_IMAGE_EDIT_MODEL: str = "doubao-seedream-5-0-260128"
AI_CHAT_MODELS: str = ""
AI_VISION_MODELS: str = ""
AI_IMAGE_EDIT_MODELS: str = ""
# AI 配置 — Gemini第三方代理
GEMINI_API_KEY: str = ""
GEMINI_BASE_URL: str = "https://api.apiqik.online"
# 兼容旧字段
ARK_API_KEY: str = ""
ARK_BASE_URL: str = "https://ark.cn-beijing.volces.com/api/v3"
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
model_config = SettingsConfigDict(
env_file=ENV_FILE,
env_file_encoding="utf-8",
extra="ignore"
)
def runtime_warnings(self) -> list[str]:
warnings: list[str] = []
if not ENV_FILE.exists():
warnings.append("未找到 Server/.env当前使用的是代码默认配置。")
if self.SECRET_KEY in PLACEHOLDER_SECRET_VALUES:
warnings.append("SECRET_KEY 仍是默认值,请在部署前替换。")
if self.ADMIN_TOKEN in PLACEHOLDER_SECRET_VALUES:
warnings.append("ADMIN_TOKEN 仍是默认值,请在部署前替换。")
if not self.AI_API_KEY and not self.ARK_API_KEY:
warnings.append("AI_API_KEY / ARK_API_KEY 未配置;如果走用户自带 Key 模式可忽略此项。")
return warnings
settings = Settings()
settings.DATABASE_URL = resolve_database_url(settings.DATABASE_URL)

View File

@@ -2,8 +2,10 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from app.core.config import settings
# 数据库连接字符串,默认使用 SQLite 本地文件
SQLALCHEMY_DATABASE_URL = getattr(settings, "DATABASE_URL", "sqlite:///./designercep.db")
# 数据库连接字符串SQLite 相对路径已在配置层解析为 Server 目录下的绝对路径
SQLALCHEMY_DATABASE_URL = getattr(
settings, "DATABASE_URL", "sqlite:///./designercep.db"
)
# 创建数据库引擎
engine = create_engine(
@@ -81,7 +83,8 @@ def seed_data():
db.close()
def ensure_migrations():
# 轻量级迁移: SQLite 动态添加缺失列
# 轻量级迁移:仅作为本地 SQLite 的兜底补列逻辑。
# 正式环境请优先使用 Alembic见 Server/migrations/README.md
if not SQLALCHEMY_DATABASE_URL.startswith("sqlite"):
return
with engine.connect() as conn:
@@ -152,3 +155,27 @@ def ensure_migrations():
add_col("users", "consecutive_check_in", "INTEGER DEFAULT 0")
if not has_column("users", "last_check_in_date"):
add_col("users", "last_check_in_date", "DATE NULL")
if not has_column("users", "ai_provider"):
add_col("users", "ai_provider", "VARCHAR(32) NULL")
if not has_column("users", "ai_api_key"):
add_col("users", "ai_api_key", "TEXT NULL")
if not has_column("users", "ai_base_url"):
add_col("users", "ai_base_url", "VARCHAR(255) NULL")
if not has_column("users", "ai_chat_base_url"):
add_col("users", "ai_chat_base_url", "VARCHAR(255) NULL")
if not has_column("users", "ai_vision_base_url"):
add_col("users", "ai_vision_base_url", "VARCHAR(255) NULL")
if not has_column("users", "ai_image_base_url"):
add_col("users", "ai_image_base_url", "VARCHAR(255) NULL")
if not has_column("users", "ai_model"):
add_col("users", "ai_model", "VARCHAR(120) NULL")
if not has_column("users", "ai_vision_model"):
add_col("users", "ai_vision_model", "VARCHAR(120) NULL")
if not has_column("users", "ai_image_model"):
add_col("users", "ai_image_model", "VARCHAR(120) NULL")
# chat_sessions 会话工作流字段
if not has_column("chat_sessions", "active_skill_id"):
add_col("chat_sessions", "active_skill_id", "VARCHAR(80) NULL")
if not has_column("chat_sessions", "workflow_state"):
add_col("chat_sessions", "workflow_state", "TEXT NULL")

View File

@@ -2,18 +2,39 @@ from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
import os
import logging
from app.core.config import settings
from app.api.v1 import auth, client, admin, analytics, admin_config, feature, checkin, user_profile, stats, algorithm, logs, ai_chat
from app.api.v1 import (
auth,
client,
admin,
analytics,
admin_config,
feature,
checkin,
user_profile,
stats,
algorithm,
logs,
ai_chat,
ai_pattern,
ai_identify,
)
from app.db import init_db
from datetime import datetime
from pathlib import Path
app = FastAPI(title=settings.PROJECT_NAME)
log = logging.getLogger("designercep.startup")
SERVER_DIR = Path(__file__).resolve().parents[2]
ARCHIVES_DIR = SERVER_DIR / "archives"
# Ensure archives directory exists
os.makedirs("archives", exist_ok=True)
os.makedirs(ARCHIVES_DIR, exist_ok=True)
# ========== CORS 配置 ==========
IS_DEV = os.getenv("ENV", "development") == "development"
IS_DEV = settings.ENV == "development"
if IS_DEV:
# 开发环境:保持宽松
@@ -27,9 +48,9 @@ if IS_DEV:
)
else:
# 生产环境:严格配置
allowed_origins = os.getenv("ALLOWED_ORIGINS", "").split(",")
allowed_origins = settings.ALLOWED_ORIGINS.split(",")
print(f"🔒 Running in Production Mode: CORS allowed origins: {allowed_origins}")
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
@@ -37,63 +58,93 @@ else:
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"],
)
# ✅ CEP 环境特殊处理 (Origin: null or cep://)
@app.middleware("http")
async def cep_cors_middleware(request: Request, call_next):
origin = request.headers.get("origin")
# CEP 的 Origin 是 null 或 cep://
if origin in ["null", None] or (origin and origin.startswith("cep://")):
response = await call_next(request)
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
response.headers["Access-Control-Allow-Methods"] = (
"GET, POST, PUT, DELETE, OPTIONS"
)
response.headers["Access-Control-Allow-Headers"] = "*"
return response
return await call_next(request)
app.include_router(auth.router, prefix=f"{settings.API_V1_STR}/auth", tags=["authentication"])
app.include_router(client.router, prefix=f"{settings.API_V1_STR}/client", tags=["client"])
app.include_router(
auth.router, prefix=f"{settings.API_V1_STR}/auth", tags=["authentication"]
)
app.include_router(
client.router, prefix=f"{settings.API_V1_STR}/client", tags=["client"]
)
app.include_router(admin.router, prefix=f"{settings.API_V1_STR}/admin", tags=["admin"])
app.include_router(analytics.router, prefix=f"{settings.API_V1_STR}/analytics", tags=["analytics"])
app.include_router(
analytics.router, prefix=f"{settings.API_V1_STR}/analytics", tags=["analytics"]
)
# 新增路由
app.include_router(admin_config.router, prefix=settings.API_V1_STR, tags=["admin-config"])
app.include_router(
admin_config.router, prefix=settings.API_V1_STR, tags=["admin-config"]
)
app.include_router(feature.router, prefix=settings.API_V1_STR, tags=["feature"])
app.include_router(checkin.router, prefix=settings.API_V1_STR, tags=["checkin"])
app.include_router(user_profile.router, prefix=settings.API_V1_STR, tags=["user-profile"])
app.include_router(
user_profile.router, prefix=settings.API_V1_STR, tags=["user-profile"]
)
app.include_router(stats.router, prefix=settings.API_V1_STR, tags=["stats"])
app.include_router(algorithm.router, prefix=settings.API_V1_STR, tags=["algorithm"])
app.include_router(logs.router, prefix=settings.API_V1_STR, tags=["logs"])
app.include_router(ai_chat.router, prefix=settings.API_V1_STR, tags=["ai-chat"])
app.include_router(ai_pattern.router, prefix=settings.API_V1_STR, tags=["ai-pattern"])
app.include_router(ai_identify.router, prefix=settings.API_V1_STR, tags=["ai-identify"])
# Health Check
@app.get("/health")
def health_check():
return {"status": "healthy", "timestamp": datetime.now().isoformat(), "env": "development" if IS_DEV else "production"}
return {
"status": "healthy",
"timestamp": datetime.now().isoformat(),
"env": "development" if IS_DEV else "production",
}
# ========== Static Files (Development Only) ==========
if IS_DEV:
# Mount archives directory for download
app.mount("/download", StaticFiles(directory="archives"), name="download")
app.mount("/download", StaticFiles(directory=ARCHIVES_DIR), name="download")
else:
print(" Production Mode: Static files are NOT mounted by FastAPI (handled by Caddy/Nginx).")
print(
" Production Mode: Static files are NOT mounted by FastAPI (handled by Caddy/Nginx)."
)
@app.get("/")
def read_root():
from fastapi.responses import RedirectResponse
if IS_DEV:
# 重定向到 Shell 登录页
return RedirectResponse(url="/shell/index.html")
return {"message": "DesignerCEP API is running"}
@app.on_event("startup")
def on_startup():
# 应用启动时初始化数据库(创建表)
init_db()
for warning in settings.runtime_warnings():
log.warning("[Config] %s", warning)
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)

View File

@@ -15,6 +15,8 @@ class ChatSession(Base):
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
username = Column(String(64), nullable=False, index=True)
title = Column(String(200), default="新对话") # 对话标题(取首条消息摘要)
active_skill_id = Column(String(80), nullable=True)
workflow_state = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -32,6 +32,17 @@ class User(Base):
vip_expire = Column(DateTime(timezone=True), nullable=True)
vip_daily_quota = Column(Integer, default=0)
vip_quota_reset_date = Column(Date, nullable=True)
# User-owned AI credentials (backend forwards requests using the user's own key)
ai_provider = Column(String(32), nullable=True)
ai_api_key = Column(Text, nullable=True)
ai_base_url = Column(String(255), nullable=True)
ai_chat_base_url = Column(String(255), nullable=True)
ai_vision_base_url = Column(String(255), nullable=True)
ai_image_base_url = Column(String(255), nullable=True)
ai_model = Column(String(120), nullable=True)
ai_vision_model = Column(String(120), nullable=True)
ai_image_model = Column(String(120), nullable=True)
total_check_in_days = Column(Integer, default=0)
consecutive_check_in = Column(Integer, default=0)