"""AI智能排版路由 - 文本排版 + 自动配图"""
import json
import uuid
import httpx
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from database import get_db
from models.user import User
from models.ai_model import AIModelConfig
from routers.auth import get_current_user
from services.ai_service import ai_service
router = APIRouter(prefix="/api/ai", tags=["AI智能排版"])
FORMAT_SYSTEM_PROMPT = """你是一位专业的文章排版编辑。你的任务是将用户提供的文章内容重新排版为结构清晰、可读性强的 Markdown 格式。
【核心原则】绝对不能修改、删除、改写或替换原文的任何文字内容。原文的每一个字、每一句话都必须原样保留。你可以在合适的位置补充过渡语、小结或说明文字来增强可读性,但必须与原文明确区分,且不能改动原文已有的文字。
排版规则:
1. 分析文章结构,在合适位置添加标题层级(## 和 ###),标题文字从原文中提取
2. 将原文中的要点整理为列表(有序或无序),但列表内容必须是原文原句
3. 重要观点用引用块 > 包裹,引用内容必须是原文原句
4. 关键词和重要内容用 **加粗** 标记
5. 保留原文中所有图片链接(格式),不要修改或删除
6. 保留原文中所有URL链接,转为 [链接文字](url) 格式
7. 适当添加分隔线 --- 划分章节
8. 长段落拆分为短段落,提高可读性,但不能改变段落中的文字
9. 如果内容中有流程或步骤,用有序列表清晰展示
10. 不要添加原文中没有的文字、解释、总结或过渡语
同时,你需要分析文章内容,为文章建议 1-2 张配图。对于每张配图,在合适的位置插入占位符,格式为:
[AI_IMAGE: 图片描述prompt,用英文写,描述要生成的图片内容,风格简洁专业]
注意:
- 占位符要插在文章逻辑合适的位置(如章节开头、流程说明旁边)
- prompt 用英文描述,风格:flat illustration, modern, professional, tech style
- 不要超过 2 个图片占位符
- 如果文章内容不适合配图(如纯代码、纯链接列表),可以不加占位符"""
class FormatRequest(BaseModel):
model_config = {"protected_namespaces": ()}
content: str
generate_images: bool = False # 是否生成配图(默认关闭)
model_config_id: Optional[int] = None # 指定排版用的文本模型
class FormatResponse(BaseModel):
formatted_content: str
images_generated: int = 0
def _get_image_model(db: Session):
"""从数据库查找已配置的图像生成模型(Seedream endpoint)"""
# 查找 task_type 包含 image 或 model_name 包含 seedream 的模型
model = db.query(AIModelConfig).filter(
AIModelConfig.is_enabled == True,
AIModelConfig.task_type == "image",
).first()
if model:
return {
"api_key": model.api_key,
"base_url": model.base_url or "https://ark.cn-beijing.volces.com/api/v3",
"model_id": model.model_id,
}
return None
async def _generate_image(api_key: str, base_url: str, model_id: str, prompt: str) -> Optional[bytes]:
"""调用火山方舟 Seedream 生成图片,返回图片二进制数据"""
url = f"{base_url.rstrip('/')}/images/generations"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"model": model_id,
"prompt": prompt,
"response_format": "url",
"size": "1920x1080", # 满足 Seedream 5.0 最低像素要求
}
try:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(url, json=payload, headers=headers)
if resp.status_code != 200:
print(f"图像生成失败: {resp.status_code} - {resp.text}")
return None
data = resp.json()
image_url = data.get("data", [{}])[0].get("url", "")
if not image_url:
return None
# 下载图片(因为 Seedream 返回的是临时链接)
img_resp = await client.get(image_url)
if img_resp.status_code == 200:
return img_resp.content
return None
except Exception as e:
print(f"图像生成异常: {e}")
return None
def _upload_to_cos(db: Session, image_data: bytes) -> Optional[str]:
"""将图片上传到 COS,返回永久 URL"""
from models.system_config import SystemConfig
keys = ["cos_secret_id", "cos_secret_key", "cos_bucket", "cos_region", "cos_custom_domain"]
config = {}
for k in keys:
row = db.query(SystemConfig).filter(SystemConfig.key == k).first()
config[k] = row.value if row else ""
secret_id = config.get("cos_secret_id", "")
secret_key = config.get("cos_secret_key", "")
bucket = config.get("cos_bucket", "")
region = config.get("cos_region", "")
if not all([secret_id, secret_key, bucket, region]):
return None
try:
from qcloud_cos import CosConfig, CosS3Client
cos_config = CosConfig(Region=region, SecretId=secret_id, SecretKey=secret_key)
client = CosS3Client(cos_config)
date_prefix = datetime.now().strftime("%Y/%m")
filename = f"{uuid.uuid4().hex}.png"
object_key = f"images/{date_prefix}/{filename}"
client.put_object(
Bucket=bucket,
Body=image_data,
Key=object_key,
ContentType="image/png",
)
custom_domain = config.get("cos_custom_domain", "")
if custom_domain:
return f"https://{custom_domain}/{object_key}"
return f"https://{bucket}.cos.{region}.myqcloud.com/{object_key}"
except Exception as e:
print(f"COS上传失败: {e}")
return None
@router.post("/format", response_model=FormatResponse)
async def format_article(
req: FormatRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""AI智能排版:格式化文章 + 自动生成配图"""
if not req.content.strip():
raise HTTPException(status_code=400, detail="文章内容不能为空")
# 第1步:AI 排版文本
messages = [{"role": "user", "content": req.content}]
try:
formatted = await ai_service.chat(
task_type="reasoning",
messages=messages,
system_prompt=FORMAT_SYSTEM_PROMPT,
stream=False,
model_config_id=req.model_config_id,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"AI排版失败: {str(e)}")
if not isinstance(formatted, str):
raise HTTPException(status_code=500, detail="AI排版返回格式异常")
# 过滤掉思考过程(DeepSeek Reasoner 会输出 ...)
import re as _re
formatted = _re.sub(r'[\s\S]*?\s*', '', formatted).strip()
# 也过滤 格式的思考过程
formatted = _re.sub(r'[\s\S]*? \s*', '', formatted).strip()
images_generated = 0
# 第2步:生成配图(如果启用)
if req.generate_images:
import re
placeholders = re.findall(r'\[AI_IMAGE:\s*(.+?)\]', formatted)
if placeholders:
image_model = _get_image_model(db)
if image_model:
for prompt in placeholders:
image_data = await _generate_image(
image_model["api_key"],
image_model["base_url"],
image_model["model_id"],
prompt.strip(),
)
if image_data:
# 上传到 COS
cos_url = _upload_to_cos(db, image_data)
if cos_url:
formatted = formatted.replace(
f"[AI_IMAGE: {prompt}]",
f"![{prompt.strip()[:50]}]({cos_url})",
1,
)
images_generated += 1
continue
# 生成或上传失败,移除占位符
formatted = formatted.replace(f"[AI_IMAGE: {prompt}]", "", 1)
else:
# 没有配置图像模型,清理所有占位符
formatted = re.sub(r'\[AI_IMAGE:\s*.+?\]', '', formatted)
# 清理可能残留的占位符
import re
formatted = re.sub(r'\[AI_IMAGE:\s*.+?\]', '', formatted)
# 清理多余空行
formatted = re.sub(r'\n{3,}', '\n\n', formatted).strip()
return FormatResponse(
formatted_content=formatted,
images_generated=images_generated,
)