初始提交:极码 GeekCode 全栈项目(FastAPI + Vue3)
This commit is contained in:
223
backend/routers/ai_format.py
Normal file
223
backend/routers/ai_format.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""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 会输出 <think>...</think>)
|
||||
import re as _re
|
||||
formatted = _re.sub(r'<think>[\s\S]*?</think>\s*', '', formatted).strip()
|
||||
# 也过滤 <details> 格式的思考过程
|
||||
formatted = _re.sub(r'<details>[\s\S]*?</details>\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,
|
||||
)
|
||||
Reference in New Issue
Block a user