修改一些直接使用中垒火焱大模型网关的服务

This commit is contained in:
silk 2026-05-14 22:05:37 +08:00
parent 496c478aa2
commit 4c0474fcfe
7 changed files with 366 additions and 397 deletions

3
.gitignore vendored
View File

@ -69,6 +69,8 @@ backend/logs/
.env.* .env.*
!.env.example !.env.example
!**/.env.example !**/.env.example
!.env.docker
!**/.env.docker
# IDE # IDE
.vscode/ .vscode/
@ -99,3 +101,4 @@ Thumbs.db
# 本地导出的 IDE / 对话历史等(按需) # 本地导出的 IDE / 对话历史等(按需)
history.txt history.txt
.cursor/ .cursor/
backend/.env.docker

View File

@ -2,6 +2,8 @@ import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue"; import vue from "@vitejs/plugin-vue";
export default defineConfig({ export default defineConfig({
// Docker 部署在 /admin/ 子路径下时需构建前设置 VITE_BASE_PATH=/admin/
base: process.env.VITE_BASE_PATH || "/",
plugins: [vue()], plugins: [vue()],
server: { server: {
port: 5174, port: 5174,

View File

@ -10,8 +10,24 @@ APP.NAME=星云 API Server
DB_HOST=106.15.186.110 DB_HOST=106.15.186.110
DB_PORT=5432 DB_PORT=5432
DB_NAME=qiyeban_huoyanai DB_NAME=qiyeban_huoyanai
DB_USER=zuoleiroot DB_USER=changeme_in_production
DB_PASSWORD=C1C0DDleRy4wgSkD DB_PASSWORD=changeme_in_production
# ==================== Neo4j 图数据库配置 ====================
NEO4J_URI=bolt://ip:7687
NEO4J_USER=neo4j
NEO4J_PASSWORD=graph123
# 数据库连接池配置
DB_POOL_MIN_SIZE=5
DB_POOL_MAX_SIZE=20
DB_COMMAND_TIMEOUT=60
CHECKPOINTER_POOL_MAX_SIZE=20
# ==================== ChromaDB与原项目同网络====================
CHROMA_HOST=106.15.186.110
CHROMA_PORT=9527
# ==================== JWT 认证配置 ==================== # ==================== JWT 认证配置 ====================
@ -32,23 +48,18 @@ logging.enable_console=True
# HTTP 请求超时时间(秒) # HTTP 请求超时时间(秒)
HTTPX_DEFAULT_TIMEOUT=120 HTTPX_DEFAULT_TIMEOUT=120
# ==================== 代理配置 ==================== # ==================== 业务 / 第三方 MCP ====================
# 如果需要通过代理访问 GitHub可选
# HTTP_PROXY=http://127.0.0.1:7890
# HTTPS_PROXY=http://127.0.0.1:7890
MCP_JUHE_TOKEN=SLIC4Zv3KnCkxyOYsZj4FabImp0RDdz8Td17Io0Tn2YHio MCP_JUHE_TOKEN=SLIC4Zv3KnCkxyOYsZj4FabImp0RDdz8Td17Io0Tn2YHio
OSS_ACCESS_KEY_ID = 'LTAI5tFGRDXbWyCzJL2e8Apd'
OSS_ACCESS_KEY_SECRET = 'QMEBsDhuAX6YwSmAbbILvsA7WFU58w' # ==================== 阿里云 OSS按需====================
OSS_ACCESS_KEY_ID = changeme_in_production
OSS_ACCESS_KEY_SECRET = changeme_in_production
OSS_ENDPOINT = 'https://oss-cn-hangzhou.aliyuncs.com' # 根据你的区域修改 OSS_ENDPOINT = 'https://oss-cn-hangzhou.aliyuncs.com' # 根据你的区域修改
OSS_BUCKET_NAME = changeme_in_production
OSS_BUCKET_NAME = 'zhongleiai'
CHROMA_HOST=106.15.186.110
CHROMA_PORT=9527
# RAG 配置 # ==================== RAG / Embedding ====================
RAG_CHUNK_SIZE=512 # 文本分块大小 RAG_CHUNK_SIZE=512 # 文本分块大小
RAG_CHUNK_OVERLAP=50 # 分块重叠大小 RAG_CHUNK_OVERLAP=50 # 分块重叠大小
RAG_TOP_K=5 # 检索返回的文档数量 RAG_TOP_K=5 # 检索返回的文档数量
@ -59,27 +70,28 @@ EMBEDDING_MODEL=text-embedding-v4 # 通义千问 Embedding 模型
EMBEDDING_DIMENSION=1536 # Embedding 维度 EMBEDDING_DIMENSION=1536 # Embedding 维度
# OCR_ACCESS_KEY_ID=LTAI5tE5oGfC37bh3Vg1KLNK # ==================== 阿里云 OCR ====================
# OCR_ACCESS_KEY_SECRET=WBAa3Fh8Tw9Kvgx4zzagWDOcPlSp4L OCR_ACCESS_KEY_ID=changeme_in_production
# OCR_ENDPOINT=ocr-api.cn-hangzhou.aliyuncs.com OCR_ACCESS_KEY_SECRET=changeme_in_production
# OCR_USE_LOCAL=false
OCR_ACCESS_KEY_ID=LTAI5tHAbs3umUtnMS1yR8Ti
OCR_ACCESS_KEY_SECRET=eByWHxrWrrDtKgOmKIu9jood6RTtwS
OCR_ENDPOINT=ocr-api.cn-hangzhou.aliyuncs.com OCR_ENDPOINT=ocr-api.cn-hangzhou.aliyuncs.com
OCR_USE_LOCAL=false OCR_USE_LOCAL=false
MODERATION_ENABLED=false MODERATION_ENABLED=false
# ==================== Neo4j 图数据库配置 ==================== # DEEPSEEK_API_KEY=sk-changeme_in_production
NEO4J_URI=bolt://47.110.73.142:7687 # DASHSCOPE_API_KEY=sk-changeme_in_production
NEO4J_USER=neo4j # DEEPSEEK_API_BASE=https://api.zlapi.com.cn/api/v1
NEO4J_PASSWORD=graph123 # DASHSCOPE_API_BASE=https://api.zlapi.com.cn/api/v1
DEEPSEEK_API_KEY=sk-CvmggZnFVo0JlaBOa1EL9FRjn4bEprK
DASHSCOPE_API_KEY=sk-CvmggZnFVo0JlaBOa1EL9FRjn4bEprK
DEEPSEEK_API_BASE=https://api.zlapi.com.cn/api/v1
DASHSCOPE_API_BASE=https://api.zlapi.com.cn/api/v1
# 通义ChatOpenAI / 视觉等走此兼容 base如 .../compatible-mode/v1。USE_ORIGIN_MODEL=true 时 ChatTongyi / 文生图等原生 SDK 会自动用同主机 .../api/v1勿把兼容 URL 直接当原生 base DEEPSEEK_API_KEY=changeme_in_production
USE_ORIGIN_MODEL=false DASHSCOPE_API_KEY=changeme_in_production
#DEEPSEEK_API_BASE=https://api.deepseek.com/v1
DASHSCOPE_API_BASE=https://dashscope.aliyuncs.com/compatible-mode/v1
# 当此处为 true 时表示聊天模型使用的是原生的厂商模型以及原生厂商地址否则使用的是中垒的d大模型网关
USE_ORIGIN_MODEL=True
# 此处为必填表示一定要使用中垒的dashscope网关设计到的服务有text_to_image, text_to_video, text_to_poster以及向量化服务的embedding模型
ZL_DASHSCOPE_API_BASE=https://api.zlapi.com.cn/api/v1
ZL_DASHSCOPE_API_KEY=changeme_in_production

16
backend/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
# Python 3.11 + uv与 pyproject 中 requires-python 一致)
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY . .
ENV PYTHONUNBUFFERED=1
EXPOSE 7861 7862
# 端口以环境变量 API.PORT / API_PORT 为准(见 core.config
CMD ["uv", "run", "python", "-m", "main"]

View File

@ -131,8 +131,8 @@ class VectorService:
print(settings.dashscope_api_key, settings.dashscope_api_base) print(settings.dashscope_api_key, settings.dashscope_api_base)
self.embedding = OpenAIEmbeddings( self.embedding = OpenAIEmbeddings(
model="text-embedding-v4", model="text-embedding-v4",
api_key=settings.dashscope_api_key, api_key=os.getenv("ZL_DASHSCOPE_API_KEY"), # 如果您没有配置环境变量请在此处用您的API Key进行替换
base_url=settings.dashscope_api_base, base_url=os.getenv("ZL_DASHSCOPE_API_BASE"),
check_embedding_ctx_length=False, check_embedding_ctx_length=False,
) )

View File

@ -1,11 +1,12 @@
""" """
视觉模型服务 视觉模型服务
基于阿里云通义千问视觉模型 (qwen-vl-max-latest) 提供图片理解能力 基于 OpenAI 兼容 ``chat.completions`` 接口参见阿里云百炼视觉文档使用支持图像输入的多模态模型
参考 server/aaa/jenius_attachment_knowledge_base/jenius_rag_util.py 的实现 默认模型 ``qwen3-vl-plus``网关与密钥优先读 ``ZL_OPENAI_*``未配置时回退 ``DASHSCOPE_*`` / ``settings``
""" """
import asyncio import asyncio
import base64 import base64
import os
from typing import Optional from typing import Optional
from openai import OpenAI, AsyncOpenAI from openai import OpenAI, AsyncOpenAI
@ -15,6 +16,27 @@ from logger.logging import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
# 与 OpenAI 兼容视觉示例一致;可通过环境变量覆盖
VISION_CHAT_MODEL = (os.getenv("ZL_OPENAI_VISION_MODEL") or "qwen3-vl-plus").strip()
def _vision_openai_api_key() -> str:
zl = (os.getenv("ZL_OPENAI_API_KEY") or "").strip()
if zl:
return zl
return (settings.dashscope_api_key or "").strip()
def _vision_openai_base_url() -> str:
zl = (os.getenv("ZL_OPENAI_BASE_URL") or "").strip().rstrip("/")
if zl:
return zl
return tongyi_openai_compatible_base_url().strip().rstrip("/")
def _vision_client_signature() -> tuple[str, str]:
return (_vision_openai_api_key(), _vision_openai_base_url())
def _is_vision_image_url(url: str) -> bool: def _is_vision_image_url(url: str) -> bool:
if not url: if not url:
@ -45,39 +67,33 @@ def image_bytes_to_data_url(image_bytes: bytes, mime_hint: Optional[str] = None)
class VisionService: class VisionService:
"""视觉模型服务类 """视觉模型服务OpenAI SDK ``chat.completions`` + ``image_url``URL 或 base64 data URL"""
使用阿里云通义千问视觉模型进行图片理解和描述
"""
_client_cache: Optional[AsyncOpenAI] = None _client_cache: Optional[AsyncOpenAI] = None
_sync_client_cache: Optional[OpenAI] = None _sync_client_cache: Optional[OpenAI] = None
_async_client_sig: Optional[tuple[str, str]] = None
_sync_client_sig: Optional[tuple[str, str]] = None
_lock = asyncio.Lock() _lock = asyncio.Lock()
@classmethod @classmethod
async def _get_async_client(cls) -> AsyncOpenAI: async def _get_async_client(cls) -> AsyncOpenAI:
"""获取或创建异步客户端(单例模式)""" """获取或创建异步客户端(凭证变更时重建)。"""
if cls._client_cache is not None: sig = _vision_client_signature()
return cls._client_cache
async with cls._lock: async with cls._lock:
if cls._client_cache is None: if cls._client_cache is not None and cls._async_client_sig == sig:
cls._client_cache = AsyncOpenAI( return cls._client_cache
api_key=settings.dashscope_api_key, cls._async_client_sig = sig
base_url=tongyi_openai_compatible_base_url(), cls._client_cache = AsyncOpenAI(api_key=sig[0], base_url=sig[1])
)
return cls._client_cache return cls._client_cache
@classmethod @classmethod
def _get_sync_client(cls) -> OpenAI: def _get_sync_client(cls) -> OpenAI:
"""获取或创建同步客户端(单例模式)""" """获取或创建同步客户端(凭证变更时重建)。"""
if cls._sync_client_cache is not None: sig = _vision_client_signature()
if cls._sync_client_cache is not None and cls._sync_client_sig == sig:
return cls._sync_client_cache return cls._sync_client_cache
cls._sync_client_sig = sig
cls._sync_client_cache = OpenAI( cls._sync_client_cache = OpenAI(api_key=sig[0], base_url=sig[1])
api_key=settings.dashscope_api_key,
base_url=tongyi_openai_compatible_base_url(),
)
return cls._sync_client_cache return cls._sync_client_cache
@classmethod @classmethod
@ -104,26 +120,20 @@ class VisionService:
client = await cls._get_async_client() client = await cls._get_async_client()
completion = await client.chat.completions.create( completion = await client.chat.completions.create(
model="qwen-vl-max-latest", model=VISION_CHAT_MODEL,
messages=[ messages=[
{
"role": "system",
"content": [{"type": "text", "text": "You are a helpful assistant."}]
},
{ {
"role": "user", "role": "user",
"content": [ "content": [
{ {"type": "image_url", "image_url": {"url": image_url}},
"type": "image_url", {"type": "text", "text": prompt},
"image_url": {"url": image_url} ],
},
{"type": "text", "text": prompt}
]
} }
] ],
) )
description = completion.choices[0].message.content description = completion.choices[0].message.content or ""
if description:
logger.info(f"成功获取图片描述: {description[:50]}...") logger.info(f"成功获取图片描述: {description[:50]}...")
return description return description
@ -142,8 +152,8 @@ class VisionService:
从内存中的图片字节获取描述异步使用 data URL 调用通义 VL 从内存中的图片字节获取描述异步使用 data URL 调用通义 VL
用于知识图谱上传等无公网 URL 的场景 用于知识图谱上传等无公网 URL 的场景
""" """
if not settings.dashscope_api_key: if not _vision_openai_api_key():
logger.warning("未配置 DASHSCOPE_API_KEY无法进行视觉理解") logger.warning("未配置 ZL_OPENAI_API_KEY 或 DASHSCOPE_API_KEY无法进行视觉理解")
return "" return ""
if not image_bytes: if not image_bytes:
return "" return ""
@ -174,26 +184,20 @@ class VisionService:
client = cls._get_sync_client() client = cls._get_sync_client()
completion = client.chat.completions.create( completion = client.chat.completions.create(
model="qwen-vl-max-latest", model=VISION_CHAT_MODEL,
messages=[ messages=[
{
"role": "system",
"content": [{"type": "text", "text": "You are a helpful assistant."}]
},
{ {
"role": "user", "role": "user",
"content": [ "content": [
{ {"type": "image_url", "image_url": {"url": image_url}},
"type": "image_url", {"type": "text", "text": prompt},
"image_url": {"url": image_url} ],
},
{"type": "text", "text": prompt}
]
} }
] ],
) )
description = completion.choices[0].message.content description = completion.choices[0].message.content or ""
if description:
logger.info(f"成功获取图片描述: {description[:50]}...") logger.info(f"成功获取图片描述: {description[:50]}...")
return description return description
@ -225,27 +229,21 @@ class VisionService:
client = await cls._get_async_client() client = await cls._get_async_client()
completion = await client.chat.completions.create( completion = await client.chat.completions.create(
model="qwen-vl-max-latest", model=VISION_CHAT_MODEL,
messages=[ messages=[
{
"role": "system",
"content": [{"type": "text", "text": "You are a helpful assistant that can analyze images and answer questions about them."}]
},
{ {
"role": "user", "role": "user",
"content": [ "content": [
{ {"type": "image_url", "image_url": {"url": image_url}},
"type": "image_url", {"type": "text", "text": question},
"image_url": {"url": image_url} ],
},
{"type": "text", "text": question}
]
} }
] ],
) )
answer = completion.choices[0].message.content answer = completion.choices[0].message.content or ""
logger.info(f"成功分析图片并回答问题") if answer:
logger.info("成功分析图片并回答问题")
return answer return answer
except Exception as e: except Exception as e:

View File

@ -3,20 +3,18 @@
定义各种 AI 工具函数包括网络搜索文生图文生视频RAG 检索等 定义各种 AI 工具函数包括网络搜索文生图文生视频RAG 检索等
""" """
import os
import time import time
import uuid import uuid
import requests import requests
from typing import Literal, Optional from typing import Literal, Optional
from http import HTTPStatus from openai import OpenAI
from langchain.tools import tool from langchain.tools import tool
from dashscope import ImageSynthesis, VideoSynthesis
import dashscope
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
import json import json
from tavily import TavilyClient from tavily import TavilyClient
from core.config import settings from core.config import settings
from core import llm_env
from utils.datetime_utils import format_beijing_time_for_agent from utils.datetime_utils import format_beijing_time_for_agent
from logger.logging import get_logger from logger.logging import get_logger
from services.vector_service import get_vector_service from services.vector_service import get_vector_service
@ -26,11 +24,6 @@ from services.oss_service import get_oss_service
logger = get_logger(__name__) logger = get_logger(__name__)
def _dashscope_http_api_base() -> str:
"""``dashscope`` 原生 SDK 使用的 HTTP 根路径(与 OpenAI 兼容 ``DASHSCOPE_API_BASE`` 可能不同)。"""
return llm_env.dashscope_native_http_api_base().strip().rstrip("/")
# 初始化 Tavily 客户端 # 初始化 Tavily 客户端
tavily_client = TavilyClient(api_key=settings.tavily_api_key) tavily_client = TavilyClient(api_key=settings.tavily_api_key)
@ -125,91 +118,90 @@ def _download_and_upload_image_to_oss(image_url: str, image_index: int) -> tuple
return image_url, None, 0.0, 0.0 return image_url, None, 0.0, 0.0
def _normalize_openai_image_size(size: str) -> str:
"""将「宽*高」转为 OpenAI 兼容接口要求的「宽x高」。"""
return (size or "").strip().replace("*", "x")
@tool @tool
def text_to_image( def text_to_image(
prompt: str, prompt: str,
negative_prompt: str = "", negative_prompt: str = "",
size: str = "1280*720", size: str = "1024x1024",
n: int = 1, n: int = 1,
prompt_extend: bool = True,
watermark: bool = False,
seed: Optional[int] = None,
) -> str: ) -> str:
""" """
文生图工具根据文本描述生成高质量图片 文生图工具根据文本描述生成高质量图片
当用户需要生成图片创建图像制作插图设计视觉内容时使用此工具 通过 OpenAI 兼容接口 ``/v1/images/generations`` 调用通义千问文生图模型如同步返回的 Qwen-Image 系列
该工具使用阿里云百炼平台的 AI 图像生成模型可以根据文字描述生成相应的图片 生成的图片会自动上传到 OSS返回永久可访问的 URL
生成的图片会自动上传到 OSS 存储返回永久可访问的 URL
使用场景 使用场景
- 用户说"生成一张...的图片""画一个...""创建...的图像" - 用户说生成一张的图片画一个创建的图像
- 需要为文章演示文稿社交媒体创建配图 - 需要配图插图或可视化某个概念/场景
- 用户想要可视化某个概念场景物体或人物
- 需要生成多个不同风格的图片供选择
参数说明 参数说明
prompt (必需): 详细描述想要生成的图片内容应该包含 prompt: 正向提示词建议详细描述主体场景风格光线与构图
- 主体对象人物动物物品等 negative_prompt: 反向提示词不需要可留空
- 场景和环境背景地点氛围 size: 分辨率OpenAI 标准 ``宽x高``也可用 ``*``会自动转为 ``x``
- 风格和艺术效果写实卡通油画水彩等 qwen-image-2.0 系列常用``2048x2048``默认 1:1``2688x1536``16:9
- 颜色和光线明亮昏暗暖色调等 ``1536x2688``9:16``2368x1728``4:3``1728x2368``3:4
- 构图和视角正面侧面俯视特写等 plus / qwen-image 系列可参考``1664x928````1472x1104````1328x1328``
示例"一只可爱的橘色小猫坐在窗台上,阳光透过窗户洒在它身上,背景是温馨的客厅,写实风格" n: 生成张数qwen-image-2.0 系列多为 16其它系列常为 1
prompt_extend: 是否开启提示词智能改写True 时模型会润色提示词
negative_prompt (可选): 描述不希望在图片中出现的内容用于排除不想要的元素 watermark: 是否在图像上加 Qwen-Image 水印
示例"模糊,低质量,文字,水印,变形,多余的手指" seed: 随机种子 [0, 2147483647]不传则由服务端随机
size (可选): 图片尺寸格式为 "宽*高"支持的官方尺寸
- "1280*1280" - 1:1 正方形适合头像图标社交媒体头像
- "800*1200" - 2:3 竖向适合手机壁纸竖版海报
- "1200*800" - 3:2 横向适合横向展示横幅
- "960*1280" - 3:4 竖向适合手机屏幕竖版内容
- "1280*960" - 4:3 横向适合传统显示器比例横版内容
- "720*1280" - 9:16 竖向适合手机竖屏短视频封面
- "1280*720" - 16:9 横向默认适合宽屏显示器视频封面网页横幅
- "1344*576" - 21:9 超宽屏适合电影比例超宽屏展示
默认值"1280*720"
n (可选): 生成图片的数量范围 1-4生成多张图片时会并行处理以提高效率
默认值1
返回值 返回值
返回包含图片的 Markdown 格式字符串图片会自动显示在对话中 Markdown 文本包含可供渲染的图片链接OSS 永久 URL失败时回退临时 URL
如果生成多张图片会按顺序展示所有图片
注意事项 注意
- 生成图片需要一定时间请耐心等待 平台返回的原始图像 URL 通常约 24 小时有效本工具会尽快转存 OSS
- 提示词越详细生成的图片质量越好
- 生成多张图片时总耗时会更长但会并行处理以提高效率
- 如果用户没有明确指定尺寸使用默认尺寸即可
""" """
try: try:
api_key = settings.dashscope_api_key api_key = (os.getenv("ZL_DASHSCOPE_API_KEY") or "").strip()
base_url = (os.getenv("ZL_DASHSCOPE_API_BASE") or "").strip().rstrip("/")
if not api_key: if not api_key:
return "错误:未配置 DASHSCOPE_API_KEY 环境变量" return "错误:未配置 DASHSCOPE_API_KEY 环境变量"
if not base_url:
return "错误:未配置 DASHSCOPE_API_BASE 环境变量"
dashscope.base_http_api_url = _dashscope_http_api_base() client = OpenAI(api_key=api_key, base_url=base_url)
model_image = "qwen-image-2.0"
logger.info(f"开始生成图片prompt: {prompt}, n={n}") size_norm = _normalize_openai_image_size(size)
n_req = max(1, min(int(n), 6))
# 创建异步任务 extra_body: dict = {
rsp = ImageSynthesis.call(api_key=api_key, "prompt_extend": prompt_extend,
model="wan2.2-t2i-flash", "watermark": watermark,
}
np = (negative_prompt or "").strip()
if np:
extra_body["negative_prompt"] = np
if seed is not None:
extra_body["seed"] = seed
logger.info(
f"开始生成图片OpenAI 兼容 images/generationsmodel={model_image}, n={n_req}, size={size_norm}"
)
response = client.images.generate(
model=model_image,
prompt=prompt, prompt=prompt,
n=n, size=size_norm,
size=size, n=n_req,
negative_prompt=negative_prompt, extra_body=extra_body,
prompt_extend=True, )
watermark=True)
print(f'response: {rsp}')
if rsp.status_code != HTTPStatus.OK:
print(f'同步调用失败, status_code: {rsp.status_code}, code: {rsp.code}, message: {rsp.message}')
return "图片生成失败"
# 提取图片 URL image_urls: list[str] = []
image_urls = [] for item in response.data or []:
if rsp.output and rsp.output.results: url = getattr(item, "url", None)
for result in rsp.output.results: if url:
if hasattr(result, 'url') and result.url: image_urls.append(url)
image_urls.append(result.url)
if not image_urls: if not image_urls:
return "图片生成完成但未获取到图片URL" return "图片生成完成但未获取到图片URL"
@ -266,12 +258,9 @@ def text_to_image(
from typing import Optional from typing import Optional
from langchain_core.tools import tool from langchain_core.tools import tool
import os
import logging import logging
import uuid import uuid
import requests import requests
from dashscope import VideoSynthesis
from http import HTTPStatus
from services.oss_service import get_oss_service from services.oss_service import get_oss_service
@ -279,141 +268,127 @@ from services.oss_service import get_oss_service
def text_to_video( def text_to_video(
prompt: str, prompt: str,
negative_prompt: str = "", negative_prompt: str = "",
size: str = "832*480", size: str = "1280x720",
duration: int = 5, duration: int = 5,
) -> str: ) -> str:
""" """
文生视频工具根据文本描述生成动态视频 文生视频工具根据文本描述生成动态视频
当用户需要生成视频创建动画制作短视频需要动态视觉内容时使用此工具 通过 OpenAI 兼容接口``POST /v1/videos`` / ``GET /v1/videos/{id}``调用万相文生视频模型
该工具使用阿里云百炼平台的 AI 视频生成模型可以根据文字描述生成相应的视频 ``wan2.6-t2v``任务为异步队列queued in_progress completed / failed
生成的视频会自动上传到 OSS 存储返回永久可访问的 URL SDK 使用 ``create_and_poll`` 自动轮询直至结束
生成的视频会自动下载并上传到 OSS返回永久可访问的播放地址
使用场景 使用场景
- 用户说"生成一个...的视频""创建一个...的动画""制作...的短视频" - 用户说生成一个的视频做一个短视频需要动态画面
- 需要为产品演示营销推广创建动态视频内容 - 产品演示营销素材社交媒体短视频脚本可视化
- 社交媒体短视频内容生成抖音快手小红书等
- 需要展示动态场景运动过程变化效果
- 用户想要可视化动态概念或过程
参数说明 参数说明
prompt (必需): 详细描述想要生成的视频内容应该包含 prompt: 视频内容描述中英文均可建议写清主体动作镜头与风格
- 主体对象和动作什么在做什么 negative_prompt: 不希望出现的内容若填写则附加到提示中约束生成方向
- 场景和环境背景地点氛围 size: 分辨率OpenAI 标准 ``宽x高``也可用 ``*``会自动转为 ``x``
- 运动方式和动态效果移动旋转变化等 例如 ``1280x720````1920x1080````720x1280``竖屏
- 风格和视觉效果写实动画电影感等 duration: 时长平台支持 **215** 超出范围会自动裁剪到该区间
- 颜色和光线明亮昏暗暖色调等
示例"一只橘色小猫在窗台上玩耍,阳光透过窗户洒在它身上,它好奇地看向窗外,背景是温馨的客厅,写实风格,画面流畅自然"
negative_prompt (可选): 描述不希望在视频中出现的内容用于排除不想要的元素
示例"模糊,低质量,文字,水印,画面抖动,不自然的运动,变形"
size (可选): 视频尺寸格式为 "宽*高"支持的尺寸
- "832*480" - 标准横向默认适合通用视频
- "1280*720" - 高清横向适合高质量视频
- "720*1280" - 竖向适合手机竖屏视频短视频平台
默认值"832*480"
duration (可选): 视频时长单位为秒支持的时长
- 5 默认适合短视频
- 10 适合中等长度视频
- 15 适合较长视频
默认值5
返回值 返回值
返回包含视频的 HTML 格式字符串视频会自动显示在对话中用户可以直接播放 包含 ``<video>`` HTML 片段可直接播放优先使用 OSS URL
视频已上传到 OSS返回的是永久可访问的 URL 平台返回的原始视频链接通常约 24 小时内有效请及时依赖 OSS 结果
注意事项 注意事项
- 视频生成需要较长时间通常需要几十秒到几分钟请耐心等待 - 生成耗时常为数十秒至数分钟请耐心等待轮询完成
- 提示词越详细生成的视频质量越好 - 提示词越具体成片越可控
- 视频生成后会自动下载并上传到 OSS这个过程可能需要额外时间
- 如果用户没有明确指定尺寸和时长使用默认值即可
- 视频生成是异步过程完成后会返回可播放的视频链接
""" """
try: try:
# 地域与 ``DASHSCOPE_API_BASE`` 一致新加坡等请改环境变量https://dashscope-intl.aliyuncs.com/api/v1 api_key = (os.getenv("ZL_DASHSCOPE_API_KEY") or "").strip()
dashscope.base_http_api_url = _dashscope_http_api_base() base_url = (os.getenv("ZL_DASHSCOPE_API_BASE") or "").strip().rstrip("/")
if not api_key:
return "错误:未配置 DASHSCOPE_API_KEY 环境变量"
if not base_url:
return "错误:未配置 DASHSCOPE_API_BASE 环境变量"
api_key = settings.dashscope_api_key client = OpenAI(api_key=api_key, base_url=base_url)
model_video = "wan2.6-t2v"
size_norm = _normalize_openai_image_size(size)
seconds = max(2, min(int(duration), 15))
full_prompt = prompt
np = (negative_prompt or "").strip()
if np:
full_prompt = f"{prompt}\n(避免出现以下内容:{np}"
resolution = "1080P" if "1920" in size_norm else "720P"
extra_body = {"resolution": resolution, "duration": seconds}
# call sync api, will return the result
start = time.time() start = time.time()
print('开始时间-->',time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())) logger.info(
rsp = VideoSynthesis.call(api_key=api_key, f"提交文生视频任务videos APImodel={model_video}, size={size_norm}, seconds={seconds}"
model='wan2.2-t2v-plus', )
prompt=prompt,
size="832*480", result = client.videos.create_and_poll(
duration=5, model=model_video,
negative_prompt=negative_prompt, prompt=full_prompt,
# audio=True, size=size_norm,
prompt_extend=True, seconds=seconds,
watermark=True) poll_interval_ms=5000,
print("请求结果:",rsp) extra_body=extra_body,
video_url = "" )
result = ""
if rsp.status_code == HTTPStatus.OK: status = getattr(result, "status", None)
print("请求链接地址:",rsp.output.video_url) video_url = getattr(result, "url", None) or ""
print("结束时间-->",time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()))
print("耗时-->",time.time()-start,"") if status != "completed" or not video_url:
video_url = rsp.output.video_url err_obj = getattr(result, "error", None)
err_msg = getattr(err_obj, "message", None) if err_obj else None
if not err_msg:
err_msg = str(status) if status else "未知状态"
logger.error(f"视频任务未成功status={status}, error={err_msg}")
return f"视频生成失败:{err_msg}"
logger.info(f"视频任务完成,耗时 {time.time() - start:.1f}s开始下载并上传 OSS{video_url}")
# 下载视频并上传到 OSS
try: try:
upload_start_time = time.time() upload_start_time = time.time()
logger.info(f"开始下载视频:{video_url}")
# 下载视频内容
download_start = time.time() download_start = time.time()
response = requests.get(video_url, timeout=300) # 5分钟超时 response = requests.get(video_url, timeout=300)
response.raise_for_status() response.raise_for_status()
video_content = response.content video_content = response.content
download_time = time.time() - download_start download_time = time.time() - download_start
logger.info(f"视频下载完成,耗时:{download_time:.2f} 秒,大小:{len(video_content) / 1024 / 1024:.2f} MB") logger.info(
f"视频下载完成,耗时:{download_time:.2f} 秒,大小:{len(video_content) / 1024 / 1024:.2f} MB"
)
# 生成 OSS 对象名称
timestamp = int(time.time()) timestamp = int(time.time())
unique_id = str(uuid.uuid4())[:8] unique_id = str(uuid.uuid4())[:8]
oss_object_name = f"videos/{timestamp}_{unique_id}.mp4" oss_object_name = f"videos/{timestamp}_{unique_id}.mp4"
# 上传到 OSS
upload_start = time.time() upload_start = time.time()
oss_service = get_oss_service() oss_service = get_oss_service()
oss_url = oss_service.upload_file_from_bytes( oss_url = oss_service.upload_file_from_bytes(
file_content=video_content, file_content=video_content,
oss_object_name=oss_object_name, oss_object_name=oss_object_name,
file_name="generated_video.mp4" file_name="generated_video.mp4",
) )
upload_time = time.time() - upload_start upload_time = time.time() - upload_start
# 计算总耗时
total_time = time.time() - upload_start_time total_time = time.time() - upload_start_time
if oss_url: if oss_url:
logger.info(f"✅ 视频已上传到 OSS{oss_url}") logger.info(f"✅ 视频已上传到 OSS{oss_url}")
logger.info(f"📊 上传统计 - 下载耗时:{download_time:.2f} 秒,上传耗时:{upload_time:.2f} 秒,总耗时:{total_time:.2f}") logger.info(
# 使用 OSS URL 替换临时 URL f"📊 上传统计 - 下载:{download_time:.2f}s上传{upload_time:.2f}s总计{total_time:.2f}s"
)
video_url = oss_url video_url = oss_url
else: else:
logger.warning("⚠️ OSS 上传失败,使用原始临时 URL") logger.warning("⚠️ OSS 上传失败,使用平台返回的临时视频 URL")
logger.warning(f"📊 上传统计 - 下载耗时:{download_time:.2f} 秒,上传耗时:{upload_time:.2f} 秒,总耗时:{total_time:.2f} 秒(上传失败)")
# 如果 OSS 上传失败,继续使用原始 URL
except Exception as upload_error: except Exception as upload_error:
logger.error(f"❌ 上传视频到 OSS 失败:{upload_error}", exc_info=True) logger.error(f"❌ 上传视频到 OSS 失败:{upload_error}", exc_info=True)
# 如果上传失败,继续使用原始临时 URL
logger.warning("⚠️ 使用原始临时视频 URL") logger.warning("⚠️ 使用原始临时视频 URL")
result = f"""<video controls width="100%" style="max-width: 600px;"> logger.info(f"✅ 视频生成流程结束:{video_url}")
return f"""<video controls width="100%" style="max-width: 600px;">
<source src="{video_url}" type="video/mp4"> <source src="{video_url}" type="video/mp4">
您的浏览器不支持视频播放 您的浏览器不支持视频播放
</video>""" </video>"""
else:
print('视频请求失败, status_code: %s, code: %s, message: %s' %
(rsp.status_code, rsp.code, rsp.message))
result = "视频生成失败"
logger.info(f"✅ 视频生成完成:{video_url}")
return result
except Exception as e: except Exception as e:
error_msg = f"❌ 生成视频异常:{str(e)}" error_msg = f"❌ 生成视频异常:{str(e)}"
@ -428,95 +403,60 @@ def text_to_poster(
body_text: str = "", body_text: str = "",
prompt_text_zh: str = "", prompt_text_zh: str = "",
prompt_text_en: str = "", prompt_text_en: str = "",
size: str = "1280*1280", size: str = "2048x2048",
) -> str: ) -> str:
""" """
创意海报生成工具根据标题副标题和正文内容生成创意海报 创意海报生成工具根据标题副标题和正文生成海报
当用户需要生成海报创建宣传图制作营销图片需要创意设计时使用此工具 ``text_to_image`` 相同通过 OpenAI 兼容接口 ``/v1/images/generations``
该工具使用阿里云百炼平台的文生图万相模型根据海报文案生成相应的创意海报图片 调用通义千问文生图模型生成海报结果上传到 OSS 后以 Markdown 图片链接返回
生成的海报会自动上传到 OSS 存储返回永久可访问的 URL海报右下角带有AI生成水印 海报场景默认 **打水印**开启提示词扩展并附带画质相关反向提示
使用场景 使用场景
- 用户说"生成一张...的海报""创建一个...的宣传图""制作...的创意海报" - 生成一张海报活动宣传图营销配图
- 需要为活动产品品牌创建宣传海报
- 社交媒体营销图片生成微信微博小红书等
- 需要展示标题副标题和正文内容的创意设计
- 用户想要可视化某个主题或概念的海报
参数说明 参数说明
title (必需): 海报的主标题应该简洁有力能够吸引注意力 title: 主标题
示例"春季新品发布""限时优惠活动""品牌宣传" sub_title: 副标题
body_text: 正文过长时在 prompt 内截取约 200 字摘要
sub_title (可选): 海报的副标题用于补充说明主标题或提供更多信息 prompt_text_zh / prompt_text_en: 视觉风格描述均未填则用默认高质量海报风格
示例"全场8折起""限时3天""专业团队打造" size: 分辨率 ``宽x高``也可用 ``*``正方形海报常用 ``2048x2048``
亦可选 ``2688x1536````1536x2688`` 等与 ``text_to_image`` 一致的取值
body_text (可选): 海报的正文内容可以包含详细说明活动规则联系方式等
示例"活动时间2024年3月1日-3月31日\n活动地点:全国门店\n咨询热线400-xxx-xxxx"
prompt_text_zh (可选): 中文提示文本用于描述海报的视觉风格和设计元素
示例"小朋友画的可爱的龙,白色背景""温馨的节日氛围,红色和金色主题"
如果未提供将根据标题和副标题自动生成
prompt_text_en (可选): 英文提示文本用于描述海报的视觉风格和设计元素
示例"Children draw a lovely dragon, white background""Warm festive atmosphere, red and gold theme"
如果未提供将根据标题和副标题自动生成
注意prompt_text_zh prompt_text_en 至少需要设置其中一个
size string 可选
输出图像的分辨率格式为宽*
默认值为 1280*1280
总像素在 [1280*1280, 1440*1440] 之间且宽高比范围为 [1:4, 4:1]例如768*2700符合要求
示例值1280*1280
常见比例推荐的分辨率
1:11280*1280
3:41104*1472
4:31472*1104
9:16960*1696
16:91696*960
返回值 返回值
返回包含海报图片的 Markdown 格式字符串海报会自动显示在对话中 Markdown 文本海报图片 URLOSS及标题信息
注意事项 注意
- 生成海报需要一定时间请耐心等待 原始生成 URL 有效期有限请以 OSS 链接为准
- 标题副标题和正文内容越清晰生成的海报质量越好
- 生成的海报带有 AI 水印标识
""" """
try: try:
api_key = settings.dashscope_api_key api_key = (os.getenv("ZL_DASHSCOPE_API_KEY") or "").strip()
base_url = (os.getenv("ZL_DASHSCOPE_API_BASE") or "").strip().rstrip("/")
if not api_key: if not api_key:
return "错误:未配置 DASHSCOPE_API_KEY 环境变量" return "错误:未配置 DASHSCOPE_API_KEY 环境变量"
if not base_url:
return "错误:未配置 DASHSCOPE_API_BASE 环境变量"
dashscope.base_http_api_url = _dashscope_http_api_base() client = OpenAI(api_key=api_key, base_url=base_url)
model_image = "qwen-image-2.0"
logger.info(f"开始生成创意海报title: {title}, sub_title: {sub_title}, body_text: {(body_text[:50] + '...') if len(body_text) > 50 else body_text}") logger.info(
f"开始生成创意海报OpenAI 兼容 images/generationstitle: {title}, "
f"sub_title: {sub_title}, body_text: {(body_text[:50] + '...') if len(body_text) > 50 else body_text}"
)
# 构建海报专用 prompt将标题、副标题、正文与视觉风格融合为文生图提示词 prompt_parts = ["创意海报设计,宣传海报,专业排版,醒目吸睛,文字清晰可读"]
prompt_parts = ["创意海报设计,宣传海报,专业排版,醒目吸睛"]
if title: if title:
prompt_parts.append(f"主标题:{title}") prompt_parts.append(f"主标题:{title}")
if sub_title: if sub_title:
prompt_parts.append(f"副标题:{sub_title}") prompt_parts.append(f"副标题:{sub_title}")
if body_text: if body_text:
# 正文可能较长,截取关键信息(限制约 200 字符)
body_summary = body_text.replace("\n", " ")[:200] body_summary = body_text.replace("\n", " ")[:200]
if len(body_text) > 200: if len(body_text) > 200:
body_summary += "..." body_summary += "..."
prompt_parts.append(f"正文内容:{body_summary}") prompt_parts.append(f"正文内容:{body_summary}")
# 视觉风格:优先使用用户提供的提示词
if prompt_text_zh: if prompt_text_zh:
prompt_parts.append(f"视觉风格:{prompt_text_zh}") prompt_parts.append(f"视觉风格:{prompt_text_zh}")
elif prompt_text_en: elif prompt_text_en:
@ -527,28 +467,27 @@ def text_to_poster(
prompt = "".join(prompt_parts) prompt = "".join(prompt_parts)
logger.info(f"海报生成 prompt: {prompt[:200]}...") logger.info(f"海报生成 prompt: {prompt[:200]}...")
# 使用与 text_to_image 相同的文生图 API万相 wan2.2-t2i-flash size_norm = _normalize_openai_image_size(size)
# 海报需要水印,故 watermark=True negative = "低分辨率,低画质,画面模糊,文字扭曲,构图混乱,画面过饱和"
rsp = ImageSynthesis.call( extra_body: dict = {
api_key=api_key, "prompt_extend": True,
model="wan2.5-t2i-preview", "watermark": True,
"negative_prompt": negative,
}
response = client.images.generate(
model=model_image,
prompt=prompt, prompt=prompt,
size=size_norm,
n=1, n=1,
size=size, extra_body=extra_body,
negative_prompt="低分辨率,低画质,画面模糊,文字扭曲,构图混乱,画面过饱和",
prompt_extend=True,
watermark=True,
) )
if rsp.status_code != HTTPStatus.OK: image_urls: list[str] = []
logger.error(f"海报生成失败, status_code: {rsp.status_code}, code: {rsp.code}, message: {rsp.message}") for item in response.data or []:
return f"海报生成失败:{rsp.message or '请稍后重试'}" url = getattr(item, "url", None)
if url:
image_urls = [] image_urls.append(url)
if rsp.output and rsp.output.results:
for result in rsp.output.results:
if hasattr(result, 'url') and result.url:
image_urls.append(result.url)
if not image_urls: if not image_urls:
return "海报生成完成但未获取到图片URL" return "海报生成完成但未获取到图片URL"
@ -556,7 +495,6 @@ def text_to_poster(
image_url = image_urls[0] image_url = image_urls[0]
logger.info(f"海报生成成功图片URL: {image_url},开始上传到 OSS") logger.info(f"海报生成成功图片URL: {image_url},开始上传到 OSS")
# 复用 text_to_image 的 OSS 上传逻辑
_, oss_url, _, _ = _download_and_upload_image_to_oss(image_url, 1) _, oss_url, _, _ = _download_and_upload_image_to_oss(image_url, 1)
final_url = oss_url if oss_url else image_url final_url = oss_url if oss_url else image_url