""" 工具模块 定义各种 AI 工具函数,包括网络搜索、文生图、文生视频、RAG 检索等。 """ import os import time import uuid import requests from typing import Literal, Optional from openai import OpenAI from langchain.tools import tool from concurrent.futures import ThreadPoolExecutor, as_completed import json from tavily import TavilyClient from core.config import settings from utils.datetime_utils import format_beijing_time_for_agent from logger.logging import get_logger from services.vector_service import get_vector_service from services.oss_service import get_oss_service # 获取日志记录器 logger = get_logger(__name__) # 初始化 Tavily 客户端 tavily_client = TavilyClient(api_key=settings.tavily_api_key) @tool def get_current_time() -> str: """ 获取当前中国北京时间(东八区)。 当用户询问现在几点、今天日期、星期几、或需要当前时间作为参考时调用此工具。 """ return format_beijing_time_for_agent() def internet_search( query: str, max_results: int = 5, topic: Literal["general", "news", "finance"] = "general", include_raw_content: bool = False, ): """Run a web search""" return tavily_client.search( query, max_results=max_results, include_raw_content=include_raw_content, topic=topic, ) def _download_and_upload_image_to_oss(image_url: str, image_index: int) -> tuple[str, Optional[str], float, float]: """ 下载单张图片并上传到 OSS Args: image_url: 原始图片 URL image_index: 图片索引(用于日志) Returns: tuple: (原始URL, OSS URL, 下载耗时, 上传耗时) """ upload_start_time = time.time() try: logger.info(f"开始下载图片 {image_index}:{image_url}") # 下载图片内容 download_start = time.time() response = requests.get(image_url, timeout=300) # 5分钟超时 response.raise_for_status() image_content = response.content download_time = time.time() - download_start logger.info(f"图片 {image_index} 下载完成,耗时:{download_time:.2f} 秒,大小:{len(image_content) / 1024 / 1024:.2f} MB") # 生成 OSS 对象名称 timestamp = int(time.time()) unique_id = str(uuid.uuid4())[:8] # 根据图片内容判断文件扩展名 content_type = response.headers.get('Content-Type', 'image/png') if 'jpeg' in content_type or 'jpg' in content_type: ext = 'jpg' elif 'png' in content_type: ext = 'png' elif 'webp' in content_type: ext = 'webp' else: ext = 'png' # 默认使用 png oss_object_name = f"images/{timestamp}_{unique_id}_{image_index}.{ext}" # 上传到 OSS upload_start = time.time() oss_service = get_oss_service() oss_url = oss_service.upload_file_from_bytes( file_content=image_content, oss_object_name=oss_object_name, file_name=f"generated_image_{image_index}.{ext}" ) upload_time = time.time() - upload_start total_time = time.time() - upload_start_time if oss_url: logger.info(f"✅ 图片 {image_index} 已上传到 OSS:{oss_url}") logger.info(f"📊 图片 {image_index} 上传统计 - 下载耗时:{download_time:.2f} 秒,上传耗时:{upload_time:.2f} 秒,总耗时:{total_time:.2f} 秒") return image_url, oss_url, download_time, upload_time else: logger.warning(f"⚠️ 图片 {image_index} OSS 上传失败,使用原始 URL") logger.warning(f"📊 图片 {image_index} 上传统计 - 下载耗时:{download_time:.2f} 秒,上传耗时:{upload_time:.2f} 秒,总耗时:{total_time:.2f} 秒(上传失败)") return image_url, None, download_time, upload_time except Exception as e: error_msg = f"❌ 图片 {image_index} 上传到 OSS 失败:{str(e)}" logger.error(error_msg, exc_info=True) 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 def text_to_image( prompt: str, negative_prompt: str = "", size: str = "1024x1024", n: int = 1, prompt_extend: bool = True, watermark: bool = False, seed: Optional[int] = None, ) -> str: """ 文生图工具:根据文本描述生成高质量图片。 通过 OpenAI 兼容接口 ``/v1/images/generations`` 调用通义千问文生图模型(如同步返回的 Qwen-Image 系列)。 生成的图片会自动上传到 OSS,返回永久可访问的 URL。 使用场景: - 用户说「生成一张…的图片」「画一个…」「创建…的图像」 - 需要配图、插图或可视化某个概念/场景 参数说明: 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 系列多为 1–6,其它系列常为 1。 prompt_extend: 是否开启提示词智能改写(True 时模型会润色提示词)。 watermark: 是否在图像上加 Qwen-Image 水印。 seed: 随机种子 [0, 2147483647];不传则由服务端随机。 返回值: Markdown 文本,包含可供渲染的图片链接(OSS 永久 URL,失败时回退临时 URL)。 注意: 平台返回的原始图像 URL 通常约 24 小时有效;本工具会尽快转存 OSS。 """ try: 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: return "错误:未配置 DASHSCOPE_API_KEY 环境变量" if not base_url: return "错误:未配置 DASHSCOPE_API_BASE 环境变量" client = OpenAI(api_key=api_key, base_url=base_url) model_image = "qwen-image-2.0" size_norm = _normalize_openai_image_size(size) n_req = max(1, min(int(n), 6)) extra_body: dict = { "prompt_extend": prompt_extend, "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/generations),model={model_image}, n={n_req}, size={size_norm}" ) response = client.images.generate( model=model_image, prompt=prompt, size=size_norm, n=n_req, extra_body=extra_body, ) image_urls: list[str] = [] for item in response.data or []: url = getattr(item, "url", None) if url: image_urls.append(url) if not image_urls: return "图片生成完成,但未获取到图片URL" logger.info(f"图片生成成功,共 {len(image_urls)} 张图片,开始上传到 OSS") # 使用多线程下载并上传图片到 OSS oss_urls = [] total_start_time = time.time() if len(image_urls) == 1: # 单张图片,直接处理 _, oss_url, _, _ = _download_and_upload_image_to_oss(image_urls[0], 1) oss_urls.append(oss_url if oss_url else image_urls[0]) else: # 多张图片,使用多线程并行处理 with ThreadPoolExecutor(max_workers=min(len(image_urls), 5)) as executor: # 提交所有任务 future_to_index = { executor.submit(_download_and_upload_image_to_oss, url, idx + 1): idx for idx, url in enumerate(image_urls) } # 收集结果(保持顺序) results = [None] * len(image_urls) for future in as_completed(future_to_index): idx = future_to_index[future] try: results[idx] = future.result() except Exception as e: logger.error(f"图片 {idx + 1} 处理异常:{e}", exc_info=True) results[idx] = (image_urls[idx], None, 0.0, 0.0) # 按顺序提取 OSS URL for original_url, oss_url, _, _ in results: oss_urls.append(oss_url if oss_url else original_url) total_time = time.time() - total_start_time logger.info(f"✅ 所有图片处理完成,总耗时:{total_time:.2f} 秒") # 构建返回信息(使用 markdown 格式以便前端正确显示图片) result_text = f"图片生成成功!共生成 {len(oss_urls)} 张图片,以下是图片连接,请使用 markdown 格式渲染这些图片。\n\n" for idx, url in enumerate(oss_urls, 1): # 使用 markdown 图片语法,这样前端可以正确渲染 result_text += f"{url}\n\n" return result_text except Exception as e: error_msg = f"生成图片时发生错误: {str(e)}" logger.error(error_msg, exc_info=True) return error_msg from typing import Optional from langchain_core.tools import tool import logging import uuid import requests from services.oss_service import get_oss_service @tool def text_to_video( prompt: str, negative_prompt: str = "", size: str = "1280x720", duration: int = 5, ) -> str: """ 文生视频工具:根据文本描述生成动态视频。 通过 OpenAI 兼容接口(``POST /v1/videos`` / ``GET /v1/videos/{id}``)调用万相文生视频模型 ``wan2.6-t2v``。任务为异步队列:queued → in_progress → completed / failed; SDK 使用 ``create_and_poll`` 自动轮询直至结束。 生成的视频会自动下载并上传到 OSS,返回永久可访问的播放地址。 使用场景: - 用户说「生成一个…的视频」「做一个短视频」「需要动态画面」 - 产品演示、营销素材、社交媒体短视频脚本可视化 参数说明: prompt: 视频内容描述(中英文均可,建议写清主体、动作、镜头与风格)。 negative_prompt: 不希望出现的内容;若填写则附加到提示中约束生成方向。 size: 分辨率,OpenAI 标准 ``宽x高``(也可用 ``宽*高``,会自动转为 ``x``)。 例如 ``1280x720``、``1920x1080``、``720x1280``(竖屏)等。 duration: 时长(秒),平台支持 **2–15** 秒,超出范围会自动裁剪到该区间。 返回值: 包含 ``