Download src/landppt/services/ppt_image_processor.py from biaoqing/ppt-web: direct link, hf CLI and curl.
- Browser
- Download file 103 kB
-
https://huggingface.co/spaces/biaoqing/ppt-web/resolve/main/src/landppt/services/ppt_image_processor.py
- Command line
-
hf download hf://spaces/biaoqing/ppt-web/src/landppt/services/ppt_image_processor.py
-
curl -L -o ppt_image_processor.py https://huggingface.co/spaces/biaoqing/ppt-web/resolve/main/src/landppt/services/ppt_image_processor.py
103 kB
| """ | |
| PPT图片处理器 | |
| 负责在PPT生成过程中处理图片相关逻辑,包括本地图片选择、网络图片搜索、AI图片生成 | |
| 支持多图片处理,由AI决定每种来源的图片数量 | |
| """ | |
| import logging | |
| from typing import Dict, Any, Optional, List | |
| import aiohttp | |
| import json | |
| import asyncio | |
| from pathlib import Path | |
| import re | |
| from ..ai import get_role_provider | |
| from ..core.config import ai_config | |
| from .models.slide_image_info import ( | |
| SlideImageInfo, SlideImagesCollection, SlideImageRequirements, | |
| ImageRequirement, ImageSource, ImagePurpose | |
| ) | |
| from .image.models import ImageSourceType | |
| from .prompt_asset_service import strip_base64_image_payloads_for_prompt | |
| from .prompts.system_prompts import SystemPrompts | |
| logger = logging.getLogger(__name__) | |
| class PPTImageProcessor: | |
| """PPT图片处理器""" | |
| def __init__(self, image_service=None, ai_provider=None, user_id: Optional[int] = None, provider_override: Optional[str] = None): | |
| self.image_service = image_service | |
| self.ai_provider = ai_provider | |
| self.user_id = user_id | |
| self.provider_override = provider_override | |
| self._base_url = None | |
| # 搜索缓存,避免重复搜索 | |
| self._search_cache = {} | |
| self._search_lock = asyncio.Lock() | |
| async def _text_completion(self, *, prompt: str, **kwargs): | |
| """调用角色为图片分析的模型""" | |
| # 优先使用用户数据库配置获取模型设置 | |
| if self.user_id is not None: | |
| from .db_config_service import get_user_role_provider | |
| provider, role_settings = await get_user_role_provider( | |
| self.user_id, "image_prompt", provider_override=self.provider_override | |
| ) | |
| if role_settings.get("model"): | |
| kwargs.setdefault("model", role_settings["model"]) | |
| elif self.ai_provider: | |
| provider = self.ai_provider | |
| if "model" not in kwargs: | |
| role_settings = ai_config.get_model_config_for_role("image_prompt", provider_override=self.provider_override) | |
| if role_settings.get("model"): | |
| kwargs["model"] = role_settings["model"] | |
| else: | |
| provider, role_settings = get_role_provider("image_prompt", provider_override=self.provider_override) | |
| if role_settings.get("model"): | |
| kwargs.setdefault("model", role_settings["model"]) | |
| provider_name = getattr(provider, "provider", None) | |
| if hasattr(provider_name, "value"): | |
| provider_name = provider_name.value | |
| provider_name = provider_name or provider.__class__.__name__ | |
| model_name = kwargs.get("model") or role_settings.get("model") if 'role_settings' in locals() else kwargs.get("model") | |
| if model_name == "deepseek-ai/DeepSeek-V4": | |
| model_name = "deepseek-ai/DeepSeek-V4-Flash" | |
| kwargs["model"] = model_name | |
| logger.warning( | |
| "一键配图 image_prompt 模型 deepseek-ai/DeepSeek-V4 在 SiliconFlow 不可用,已自动切换为 %s", | |
| model_name, | |
| ) | |
| logger.info( | |
| "一键配图 image_prompt 配置: provider=%s, model=%s, provider_override=%s, user_id=%s", | |
| provider_name, | |
| model_name or "<EMPTY>", | |
| self.provider_override or "<NONE>", | |
| self.user_id, | |
| ) | |
| prompt = SystemPrompts.with_text_cache_prefix(prompt) | |
| return await provider.text_completion(prompt=prompt, **kwargs) | |
| def _get_base_url(self) -> str: | |
| """获取基础URL,用于构建绝对图片链接""" | |
| from .url_service import get_current_base_url | |
| return get_current_base_url() | |
| def _build_absolute_image_url(self, relative_path: str) -> str: | |
| """构建绝对图片URL""" | |
| from .url_service import build_absolute_url | |
| return build_absolute_url(relative_path) | |
| def _get_enabled_image_sources(self, image_config: Dict[str, Any]) -> List[ImageSource]: | |
| """获取启用的图像来源""" | |
| enabled_sources = [] | |
| if image_config.get('enable_local_images', True): | |
| enabled_sources.append(ImageSource.LOCAL) | |
| if image_config.get('enable_network_search', False): | |
| enabled_sources.append(ImageSource.NETWORK) | |
| if image_config.get('enable_ai_generation', False): | |
| enabled_sources.append(ImageSource.AI_GENERATED) | |
| return enabled_sources | |
| def _normalize_network_search_provider(self, provider: Optional[str]) -> str: | |
| """Normalize provider string from config/UI.""" | |
| if not provider: | |
| return "" | |
| value = str(provider).strip().lower() | |
| aliases = { | |
| "pixbay": "pixabay", | |
| "pxabay": "pixabay", | |
| "unspalsh": "unsplash", | |
| } | |
| return aliases.get(value, value) | |
| def _is_network_provider_configured(self, provider: str, image_config: Dict[str, Any]) -> bool: | |
| """Check whether the given provider has usable credentials in the provided config.""" | |
| provider = self._normalize_network_search_provider(provider) | |
| if provider == "unsplash": | |
| key = image_config.get("unsplash_access_key") | |
| return bool(key and str(key).strip()) | |
| if provider == "pixabay": | |
| key = image_config.get("pixabay_api_key") | |
| return bool(key and str(key).strip()) | |
| if provider == "searxng": | |
| host = image_config.get("searxng_host") | |
| return bool(host and str(host).strip()) | |
| return False | |
| def _select_ai_image_provider(self, image_config: Dict[str, Any]) -> str: | |
| """ | |
| Pick the AI image provider from user image config. | |
| `default_ai_image_provider` is the explicit switch used by PPT image generation. | |
| If it is missing, fall back to a configured provider instead of always using DALL-E. | |
| """ | |
| desired = str(image_config.get("default_ai_image_provider") or "").strip().lower() | |
| aliases = { | |
| "dall-e": "dalle", | |
| "dalle3": "dalle", | |
| "dall-e-3": "dalle", | |
| "stable-diffusion": "stable_diffusion", | |
| "sd": "stable_diffusion", | |
| "openai": "openai_image", | |
| "gpt-image": "openai_image", | |
| "silicon": "siliconflow", | |
| "sf": "siliconflow", | |
| } | |
| if desired: | |
| return aliases.get(desired, desired) | |
| provider_key_map = { | |
| "siliconflow": "siliconflow_api_key", | |
| "openai_image": "openai_image_api_key", | |
| "gemini": "gemini_image_api_key", | |
| "stable_diffusion": "stability_api_key", | |
| "pollinations": "pollinations_api_key", | |
| "dalle": "openai_api_key_image", | |
| } | |
| for provider, key_name in provider_key_map.items(): | |
| key = image_config.get(key_name) | |
| if key and str(key).strip(): | |
| logger.warning( | |
| "未配置 default_ai_image_provider,自动使用已配置的AI图片提供商: %s", | |
| provider, | |
| ) | |
| return provider | |
| logger.warning("未配置 default_ai_image_provider 且没有检测到可用图片提供商,回退到 dalle") | |
| return "dalle" | |
| def _select_network_search_provider(self, image_config: Dict[str, Any]) -> Optional[str]: | |
| """ | |
| Pick a working network search provider based on DB config: | |
| - Prefer `default_network_search_provider` when configured. | |
| - Otherwise fall back to any configured provider. | |
| """ | |
| desired = self._normalize_network_search_provider(image_config.get("default_network_search_provider")) | |
| if desired and self._is_network_provider_configured(desired, image_config): | |
| return desired | |
| for candidate in ("pixabay", "unsplash", "searxng"): | |
| if self._is_network_provider_configured(candidate, image_config): | |
| return candidate | |
| return None | |
| def _clamp_requirement_count( | |
| self, | |
| source: ImageSource, | |
| raw_count: Any, | |
| image_config: Dict[str, Any], | |
| remaining_total: int, | |
| ) -> int: | |
| """将AI返回的图片数量限制在服务端配置范围内。""" | |
| try: | |
| count = int(raw_count) | |
| except (TypeError, ValueError): | |
| count = 0 | |
| if count <= 0 or remaining_total <= 0: | |
| return 0 | |
| per_source_limits = { | |
| ImageSource.LOCAL: int(image_config.get('max_local_images_per_slide', 2) or 2), | |
| ImageSource.NETWORK: int(image_config.get('max_network_images_per_slide', 2) or 2), | |
| ImageSource.AI_GENERATED: int(image_config.get('max_ai_images_per_slide', 1) or 1), | |
| } | |
| return max(0, min(count, per_source_limits.get(source, count), remaining_total)) | |
| def _clean_compact_text(self, text: Any, max_length: int = 160) -> str: | |
| """压缩文本,便于作为搜索词或兜底提示词片段。""" | |
| value = str(text or "").strip() | |
| value = re.sub(r"[\r\n\t]+", " ", value) | |
| value = re.sub(r"\s+", " ", value) | |
| value = value.strip(" -_,。,.;;::") | |
| if len(value) > max_length: | |
| value = value[:max_length].rsplit(" ", 1)[0] or value[:max_length] | |
| return value | |
| def _build_fallback_search_keywords( | |
| self, | |
| slide_title: str, | |
| slide_content: str, | |
| project_topic: str, | |
| project_scenario: str, | |
| requirement: Optional[ImageRequirement] = None, | |
| max_length: int = 90, | |
| ) -> str: | |
| """不额外调用LLM的关键词兜底。""" | |
| parts = [ | |
| requirement.description if requirement else "", | |
| slide_title, | |
| project_topic, | |
| project_scenario, | |
| ] | |
| combined = " ".join(self._clean_compact_text(part, 40) for part in parts if part) | |
| combined = re.sub(r"[^\w\u4e00-\u9fff\s-]+", " ", combined) | |
| combined = re.sub(r"\s+", " ", combined).strip() | |
| return self._truncate_search_query(combined or slide_title or project_topic or "presentation", max_length) | |
| def _get_planned_search_keywords( | |
| self, | |
| requirement: ImageRequirement, | |
| slide_title: str, | |
| slide_content: str, | |
| project_topic: str, | |
| project_scenario: str, | |
| max_length: int = 90, | |
| ) -> str: | |
| """优先使用一次图片规划返回的关键词,缺失时本地兜底,不再追加LLM调用。""" | |
| keywords = self._clean_compact_text(requirement.search_keywords, max_length) | |
| if not keywords: | |
| keywords = self._build_fallback_search_keywords( | |
| slide_title, | |
| slide_content, | |
| project_topic, | |
| project_scenario, | |
| requirement, | |
| max_length=max_length, | |
| ) | |
| return keywords | |
| def _build_fallback_generation_prompt( | |
| self, | |
| slide_title: str, | |
| slide_content: str, | |
| project_topic: str, | |
| project_scenario: str, | |
| requirement: Optional[ImageRequirement], | |
| image_index: int, | |
| ) -> str: | |
| """不额外调用LLM的AI图片提示词兜底。""" | |
| purpose = requirement.purpose.value if requirement else "illustration" | |
| description = requirement.description if requirement else "" | |
| topic = self._clean_compact_text(project_topic, 80) | |
| title = self._clean_compact_text(slide_title, 80) | |
| desc = self._clean_compact_text(description or slide_content, 140) | |
| return ( | |
| "Professional presentation visual, clean modern composition, " | |
| f"topic: {topic}, slide: {title}, purpose: {purpose}, " | |
| f"visual brief: {desc}, image {image_index}, no text, no watermark, " | |
| "high quality, suitable for a 16:9 business slide." | |
| ) | |
| def _parse_planned_dimensions( | |
| self, | |
| requirement_data: Dict[str, Any], | |
| provider_key: str, | |
| image_config: Dict[str, Any], | |
| ) -> tuple: | |
| """从一次规划结果中解析尺寸,缺失或非法时使用提供商首选尺寸。""" | |
| planned_size = ( | |
| requirement_data.get("size") | |
| or requirement_data.get("dimensions") | |
| or { | |
| "width": requirement_data.get("width"), | |
| "height": requirement_data.get("height"), | |
| } | |
| ) | |
| parsed = self._normalize_resolution_value(planned_size) | |
| options = self._get_resolution_options(provider_key, image_config) | |
| if not options: | |
| options = [(1792, 1024), (1024, 1792), (1024, 1024)] | |
| if parsed and parsed in options: | |
| return parsed | |
| return options[0] | |
| def _build_dimension_options_for_prompt(self, provider_key: str, image_config: Dict[str, Any]) -> str: | |
| """构建AI可选尺寸说明,供一次规划同时选择尺寸。""" | |
| options = self._get_resolution_options(provider_key, image_config) or [(1792, 1024), (1024, 1792), (1024, 1024)] | |
| option_lines = [] | |
| for idx, (w, h) in enumerate(options[:6], start=1): | |
| aspect = w / h | |
| if aspect > 1.1: | |
| orientation = "横向" | |
| elif aspect < 0.9: | |
| orientation = "竖向" | |
| else: | |
| orientation = "正方形" | |
| option_lines.append(f"{idx}. {w}x{h}({orientation})") | |
| return "\n".join(option_lines) | |
| async def process_slide_image(self, slide_data: Dict[str, Any], confirmed_requirements: Dict[str, Any], | |
| page_number: int, total_pages: int, template_html: str = "") -> Optional[SlideImagesCollection]: | |
| """处理幻灯片多图片生成/搜索/选择逻辑""" | |
| try: | |
| # 检查是否启用图片生成服务 - 从用户数据库配置读取(非环境变量) | |
| from .db_config_service import get_db_config_service | |
| db_config_service = get_db_config_service() | |
| image_config = await db_config_service.get_config_by_category('image_service', user_id=self.user_id) | |
| enable_image_service = image_config.get('enable_image_service', False) | |
| logger.info(f"图片服务配置 (user_id={self.user_id}): enable_image_service={enable_image_service}") | |
| if not enable_image_service: | |
| logger.info(f"第{page_number}页: 图片生成服务未启用,跳过图片处理") | |
| return None | |
| # 获取项目信息 | |
| project_topic = confirmed_requirements.get('project_topic', '') | |
| project_scenario = confirmed_requirements.get('project_scenario', 'general') | |
| slide_title = slide_data.get('title', f'第{page_number}页') | |
| slide_content = slide_data.get('content_points', []) | |
| slide_content_text = '\n'.join(slide_content) if isinstance(slide_content, list) else str(slide_content) | |
| # 检查启用的图片来源 | |
| enabled_sources = self._get_enabled_image_sources(image_config) | |
| if not enabled_sources: | |
| logger.info(f"第{page_number}页没有启用任何图片来源,跳过图片处理") | |
| return None | |
| # 让AI分析并决定图片需求(只考虑启用的来源) | |
| image_requirements = await self._ai_analyze_image_requirements( | |
| slide_data, project_topic, project_scenario, page_number, total_pages, template_html, enabled_sources, image_config | |
| ) | |
| if not image_requirements or not image_requirements.requirements: | |
| logger.info(f"AI判断第{page_number}页不需要添加图片,跳过图片处理") | |
| return None | |
| logger.info(f"第{page_number}页图片需求: 总计{image_requirements.total_images_needed}张图片") | |
| # 创建图片集合 | |
| images_collection = SlideImagesCollection(page_number=page_number, images=[]) | |
| # 根据需求处理各种来源的图片 | |
| for requirement in image_requirements.requirements: | |
| if requirement.source == ImageSource.LOCAL and ImageSource.LOCAL in enabled_sources: | |
| local_images = await self._process_local_images( | |
| requirement, project_topic, project_scenario, slide_title, slide_content_text | |
| ) | |
| images_collection.images.extend(local_images) | |
| elif requirement.source == ImageSource.NETWORK and ImageSource.NETWORK in enabled_sources: | |
| network_images = await self._process_network_images( | |
| requirement, project_topic, project_scenario, slide_title, slide_content_text, image_config | |
| ) | |
| images_collection.images.extend(network_images) | |
| elif requirement.source == ImageSource.AI_GENERATED and ImageSource.AI_GENERATED in enabled_sources: | |
| ai_images = await self._process_ai_generated_images( | |
| requirement, project_topic, project_scenario, slide_title, slide_content_text, | |
| image_config, page_number, total_pages, template_html | |
| ) | |
| images_collection.images.extend(ai_images) | |
| # 重新计算统计信息 | |
| images_collection.__post_init__() | |
| if images_collection.total_count > 0: | |
| logger.info(f"第{page_number}页成功处理{images_collection.total_count}张图片: " | |
| f"本地{images_collection.local_count}张, " | |
| f"网络{images_collection.network_count}张, " | |
| f"AI生成{images_collection.ai_generated_count}张") | |
| return images_collection | |
| else: | |
| logger.info(f"第{page_number}页未能获取到任何图片") | |
| return None | |
| except Exception as e: | |
| logger.error(f"处理幻灯片图片失败: {e}") | |
| return None | |
| async def _ai_analyze_image_requirements(self, slide_data: Dict[str, Any], project_topic: str, | |
| project_scenario: str, page_number: int, total_pages: int, | |
| template_html: str = "", enabled_sources: List[ImageSource] = None, | |
| image_config: Dict[str, Any] = None) -> Optional[SlideImageRequirements]: | |
| """使用AI分析幻灯片的图片需求""" | |
| # 提取幻灯片内容信息 | |
| slide_title = slide_data.get('title', '') | |
| slide_content = slide_data.get('content_points', []) | |
| slide_content_text = '\n'.join(slide_content) if isinstance(slide_content, list) else str(slide_content) | |
| content_length = len(slide_content_text.strip()) | |
| content_points_count = len(slide_content) if isinstance(slide_content, list) else 0 | |
| # 处理启用的来源和配置限制 | |
| if not enabled_sources: | |
| enabled_sources = [ImageSource.LOCAL, ImageSource.NETWORK, ImageSource.AI_GENERATED] | |
| if not image_config: | |
| image_config = {} | |
| max_retries = 3 | |
| for attempt in range(max_retries): | |
| try: | |
| # 获取各来源的最大数量限制 | |
| max_local = image_config.get('max_local_images_per_slide', 2) | |
| max_network = image_config.get('max_network_images_per_slide', 2) | |
| max_ai = image_config.get('max_ai_images_per_slide', 1) | |
| max_total = image_config.get('max_total_images_per_slide', 3) | |
| default_ai_provider = self._select_ai_image_provider(image_config) | |
| ai_dimension_options = self._build_dimension_options_for_prompt(default_ai_provider, image_config) | |
| # 构建启用来源的说明 | |
| enabled_sources_desc = [] | |
| if ImageSource.LOCAL in enabled_sources: | |
| enabled_sources_desc.append(f"local: 本地图床中的图片,适合通用性图片 (最多{max_local}张)") | |
| if ImageSource.NETWORK in enabled_sources: | |
| enabled_sources_desc.append(f"network: 网络搜索图片,适合特定主题的高质量图片 (最多{max_network}张)") | |
| if ImageSource.AI_GENERATED in enabled_sources: | |
| enabled_sources_desc.append( | |
| f"ai_generated: AI生成图片,适合定制化、创意性图片 (最多{max_ai}张," | |
| f"默认提供商{default_ai_provider})" | |
| ) | |
| # 构建包含模板HTML的提示词 | |
| template_context = "" | |
| if template_html.strip(): | |
| template_excerpt = strip_base64_image_payloads_for_prompt(template_html)[:500] | |
| template_context = f""" | |
| 当前PPT模板HTML参考: | |
| {template_excerpt}... | |
| """ | |
| prompt = f"""作为专业的PPT设计师,请一次性完成以下幻灯片的图片规划。先判断该页面内容是否需要或适合配图,如果不需要或不适合配图则返回0;如果需要,请同时给出图片来源、数量、搜索关键词、AI生成尺寸和AI生成提示词。 | |
| 【项目信息】 | |
| - 主题:{project_topic} | |
| - 场景:{project_scenario} | |
| - 当前页:{page_number}/{total_pages} | |
| 【幻灯片内容】 | |
| - 标题:{slide_title} | |
| - 内容要点数量:{content_points_count}个 | |
| - 内容字数:{content_length}字 | |
| - 具体内容: | |
| {slide_content_text} | |
| {template_context} | |
| 【可用图片来源及限制】 | |
| {chr(10).join(enabled_sources_desc)} | |
| 【AI生成图片可选尺寸】 | |
| 当前AI图片提供商:{default_ai_provider} | |
| {ai_dimension_options} | |
| 【图片用途说明】 | |
| 1. decoration: 装饰性图片,美化页面 | |
| 2. illustration: 说明性图片,辅助理解内容 | |
| 3. background: 背景图片,营造氛围 | |
| 4. icon: 图标,简化表达 | |
| 5. chart_support: 图表辅助,支持数据展示 | |
| 6. content_visual: 内容可视化,直观展示概念 | |
| 【配图适用性判断标准】 | |
| 请首先判断该页面是否需要或适合配图,考虑以下因素: | |
| 1. 内容类型:纯文字列表、目录页、致谢页等通常不需要配图 | |
| 2. 内容密度:文字过多的页面可能不适合添加图片 | |
| 3. 页面功能:导航页、索引页、参考文献页等功能性页面通常不需要配图 | |
| 4. 内容抽象度:过于抽象或概念性的内容可能不适合配图 | |
| 5. 版面空间:内容已经很满的页面不适合再添加图片 | |
| 【不适合配图的典型情况】 | |
| - 纯文字列表或条目 | |
| - 目录、索引、导航页面 | |
| - 致谢、参考文献页面 | |
| - 纯数据表格页面 | |
| - 文字密集的详细说明页面 | |
| - 过于抽象的理论概念页面 | |
| 【分析要求】 | |
| 如果判断适合配图,请综合考虑以下因素来决定图片需求: | |
| 1. 内容复杂度:复杂内容需要更多说明性图片 | |
| 2. 页面类型:封面页、章节页通常需要装饰性图片 | |
| 3. 视觉平衡:文字密集的页面需要图片调节 | |
| 4. 主题匹配:根据主题选择合适的图片来源 | |
| 5. 设计风格:根据模板风格决定图片类型 | |
| 【重要限制】 | |
| - 总图片数量不能超过{max_total}张 | |
| - 只能使用已启用的图片来源 | |
| - 每种来源都有数量限制,请严格遵守 | |
| - 只允许从“AI生成图片可选尺寸”中选择尺寸 | |
| - 对 local/network 需求必须直接给出 search_keywords,后续不会再调用LLM生成关键词 | |
| - 对 ai_generated 需求必须直接给出 width、height 和 generation_prompts,后续不会再调用LLM选择尺寸或生成提示词 | |
| - generation_prompts 必须是英文,每张图片一个提示词,长度不超过120词,避免文字、Logo、水印 | |
| 请以JSON格式返回分析结果,格式如下: | |
| {{ | |
| "needs_images": true/false, | |
| "total_images": 数字, | |
| "requirements": [ | |
| {{ | |
| "source": "仅限已启用的来源", | |
| "count": 数字, | |
| "purpose": "decoration/illustration/background/icon/chart_support/content_visual", | |
| "description": "具体需求描述", | |
| "priority": 1-5, | |
| "search_keywords": "local/network使用的3-6个关键词;ai_generated可为空", | |
| "width": 1792, | |
| "height": 1024, | |
| "generation_prompts": ["仅ai_generated必填,数组长度等于count,每项为英文图片生成提示词"] | |
| }} | |
| ], | |
| "reasoning": "分析理由,包括是否适合配图的判断依据" | |
| }} | |
| 【重要要求】: | |
| - 如果不需要或不适合配图,设置needs_images为false,total_images为0,requirements为空数组 | |
| - 每种来源可以有多个需求项,支持不同用途 | |
| - 优先级1-5,5为最高优先级 | |
| - 严格遵守数量限制,避免页面过于拥挤 | |
| - local/network的search_keywords要具体、可搜索;中文项目优先中文关键词,英文项目优先英文关键词 | |
| - ai_generated的generation_prompts要可直接提交给图片生成服务 | |
| - 必须返回有效的JSON格式,不要添加任何解释文字 | |
| - 不要使用markdown代码块包装 | |
| - 确保所有字符串值都用双引号包围 | |
| - 确保布尔值使用true/false(小写) | |
| 请直接返回纯JSON格式的结果:""" | |
| response = await self._text_completion( | |
| prompt=prompt, | |
| temperature=0.7 | |
| ) | |
| # 解析AI响应 | |
| # 清理AI响应内容 | |
| raw_content = response.content.strip() | |
| logger.debug(f"AI原始响应内容: {raw_content}") | |
| # 尝试提取JSON部分 | |
| json_content = self._extract_json_from_response(raw_content) | |
| if not json_content: | |
| logger.error(f"无法从AI响应中提取有效JSON: {raw_content}") | |
| raise json.JSONDecodeError("无法提取有效JSON", raw_content, 0) | |
| result = json.loads(json_content) | |
| if not result.get('needs_images', False) or result.get('total_images', 0) == 0: | |
| reasoning = result.get('reasoning', '未提供理由') | |
| logger.info(f"AI判断第{page_number}页不需要或不适合配图: {reasoning}") | |
| return None | |
| # 创建需求对象 | |
| requirements = SlideImageRequirements(page_number=page_number, requirements=[]) | |
| remaining_total = int(max_total or 0) | |
| for req_data in result.get('requirements', []): | |
| source = ImageSource(req_data['source']) | |
| if source not in enabled_sources: | |
| logger.warning(f"AI返回未启用的图片来源,已忽略: {source.value}") | |
| continue | |
| count = self._clamp_requirement_count( | |
| source, | |
| req_data.get('count', 0), | |
| image_config, | |
| remaining_total, | |
| ) | |
| if count <= 0: | |
| continue | |
| width = None | |
| height = None | |
| generation_prompts = None | |
| if source == ImageSource.AI_GENERATED: | |
| width, height = self._parse_planned_dimensions( | |
| req_data, | |
| default_ai_provider, | |
| image_config, | |
| ) | |
| raw_prompts = req_data.get("generation_prompts") or req_data.get("image_prompts") or [] | |
| if isinstance(raw_prompts, str): | |
| raw_prompts = [raw_prompts] | |
| generation_prompts = [ | |
| self._clean_compact_text(prompt, 900) | |
| for prompt in raw_prompts | |
| if self._clean_compact_text(prompt, 900) | |
| ][:count] | |
| while len(generation_prompts) < count: | |
| generation_prompts.append( | |
| self._build_fallback_generation_prompt( | |
| slide_title, | |
| slide_content_text, | |
| project_topic, | |
| project_scenario, | |
| None, | |
| len(generation_prompts) + 1, | |
| ) | |
| ) | |
| search_keywords = self._clean_compact_text(req_data.get('search_keywords'), 160) | |
| if source in (ImageSource.LOCAL, ImageSource.NETWORK) and not search_keywords: | |
| search_keywords = self._build_fallback_search_keywords( | |
| slide_title, | |
| slide_content_text, | |
| project_topic, | |
| project_scenario, | |
| None, | |
| ) | |
| requirement = ImageRequirement( | |
| source=source, | |
| count=count, | |
| purpose=ImagePurpose(req_data['purpose']), | |
| description=req_data['description'], | |
| priority=req_data.get('priority', 1), | |
| search_keywords=search_keywords or None, | |
| width=width, | |
| height=height, | |
| generation_prompts=generation_prompts, | |
| ) | |
| requirements.add_requirement(requirement) | |
| remaining_total -= count | |
| if remaining_total <= 0: | |
| break | |
| logger.info(f"AI分析第{page_number}页图片需求: {result.get('reasoning', '')}") | |
| return requirements | |
| except (json.JSONDecodeError, KeyError, ValueError) as e: | |
| logger.warning(f"第{attempt + 1}次尝试解析AI图片需求分析结果失败: {e}") | |
| logger.debug(f"AI响应内容: {response.content}") | |
| if attempt < max_retries - 1: | |
| logger.info(f"等待1秒后进行第{attempt + 2}次重试...") | |
| import asyncio | |
| await asyncio.sleep(1) | |
| continue | |
| else: | |
| logger.error(f"所有{max_retries}次尝试都失败,放弃图片需求分析") | |
| return None | |
| except Exception as e: | |
| logger.warning(f"第{attempt + 1}次尝试AI分析图片需求失败: {e}") | |
| if attempt < max_retries - 1: | |
| logger.info(f"等待1秒后进行第{attempt + 2}次重试...") | |
| import asyncio | |
| await asyncio.sleep(1) | |
| continue | |
| else: | |
| logger.error(f"所有{max_retries}次尝试都失败,放弃图片需求分析") | |
| return None | |
| # 如果所有重试都失败了 | |
| logger.error("AI分析图片需求失败,已达到最大重试次数") | |
| return None | |
| def _extract_json_from_response(self, content: str) -> Optional[str]: | |
| """从AI响应中提取JSON内容""" | |
| try: | |
| # 移除可能的 think 内容 | |
| content = content.split("</think>")[-1] | |
| # 移除可能的markdown代码块标记 | |
| content = content.strip() | |
| # 如果内容被```json包围,提取其中的内容 | |
| if content.startswith('```json') and content.endswith('```'): | |
| content = content[7:-3].strip() | |
| elif content.startswith('```') and content.endswith('```'): | |
| content = content[3:-3].strip() | |
| # 查找第一个{和最后一个} | |
| start_idx = content.find('{') | |
| end_idx = content.rfind('}') | |
| if start_idx != -1 and end_idx != -1 and start_idx < end_idx: | |
| json_content = content[start_idx:end_idx + 1] | |
| # 验证是否为有效JSON | |
| json.loads(json_content) | |
| return json_content | |
| # 如果直接是JSON格式 | |
| json.loads(content) | |
| return content | |
| except (json.JSONDecodeError, ValueError): | |
| pass | |
| return None | |
| async def _process_local_images(self, requirement: ImageRequirement, project_topic: str, | |
| project_scenario: str, slide_title: str, slide_content: str) -> List[SlideImageInfo]: | |
| """处理本地图片需求""" | |
| images = [] | |
| try: | |
| if not self.image_service: | |
| logger.warning("图片服务未初始化") | |
| return images | |
| # 获取本地图片库信息 | |
| cache_stats = await self.image_service.get_cache_stats() | |
| total_images = 0 | |
| if 'categories' in cache_stats: | |
| for _, count in cache_stats['categories'].items(): | |
| total_images += count | |
| if total_images == 0: | |
| logger.info("本地图片库为空,跳过本地图片选择") | |
| return images | |
| # 优先复用一次图片规划中的关键词,避免为本地图片再调用LLM。 | |
| search_keywords = self._get_planned_search_keywords( | |
| requirement, | |
| slide_title, | |
| slide_content, | |
| project_topic, | |
| project_scenario, | |
| max_length=90, | |
| ) | |
| if not search_keywords: | |
| logger.warning("无法生成本地搜索关键词") | |
| return images | |
| # 搜索并选择多张图片 | |
| selected_images = await self._search_multiple_local_images(search_keywords, requirement.count) | |
| for image_id in selected_images: | |
| relative_url = f"/api/image/view/{image_id}" | |
| absolute_url = self._build_absolute_image_url(relative_url) | |
| # 获取图片详细信息 | |
| image_info = await self._get_local_image_details(image_id) | |
| slide_image = SlideImageInfo( | |
| image_id=image_id, | |
| absolute_url=absolute_url, | |
| source=ImageSource.LOCAL, | |
| purpose=requirement.purpose, | |
| content_description=requirement.description, | |
| search_keywords=search_keywords, | |
| alt_text=image_info.get('title', ''), | |
| title=image_info.get('title', ''), | |
| width=image_info.get('width'), | |
| height=image_info.get('height'), | |
| file_size=image_info.get('file_size'), | |
| format=image_info.get('format') | |
| ) | |
| images.append(slide_image) | |
| logger.info(f"成功选择{len(images)}张本地图片") | |
| return images | |
| except Exception as e: | |
| logger.error(f"处理本地图片失败: {e}") | |
| return images | |
| async def _process_network_images(self, requirement: ImageRequirement, project_topic: str, | |
| project_scenario: str, slide_title: str, slide_content: str, | |
| image_config: Dict[str, Any]) -> List[SlideImageInfo]: | |
| """处理网络图片需求""" | |
| images = [] | |
| try: | |
| desired_provider = self._normalize_network_search_provider( | |
| image_config.get("default_network_search_provider") or "unsplash" | |
| ) | |
| provider = self._select_network_search_provider(image_config) | |
| # 检查是否有可用的网络搜索提供商 | |
| if not provider: | |
| logger.warning("没有配置可用的网络搜索提供商") | |
| # 添加详细的配置检查信息 | |
| logger.warning(f"默认网络搜索提供商: {desired_provider}") | |
| logger.warning(f"Unsplash API Key: {'已配置' if image_config.get('unsplash_access_key') else '未配置'}") | |
| logger.warning(f"Pixabay API Key: {'已配置' if image_config.get('pixabay_api_key') else '未配置'}") | |
| logger.warning(f"SearXNG Host: {'已配置' if image_config.get('searxng_host') else '未配置'}") | |
| return images | |
| if provider != desired_provider: | |
| logger.warning(f"默认网络搜索提供商'{desired_provider}'不可用,降级使用'{provider}'") | |
| # 优先复用一次图片规划中的关键词,避免为网络搜索再调用LLM。 | |
| max_length = 100 if provider == 'pixabay' else 200 | |
| search_query = self._get_planned_search_keywords( | |
| requirement, | |
| slide_title, | |
| slide_content, | |
| project_topic, | |
| project_scenario, | |
| max_length=max_length, | |
| ) | |
| if not search_query: | |
| logger.warning("无法生成搜索关键词") | |
| return images | |
| # logger.info(f"网络搜索关键词: {search_query}") | |
| # 搜索更多图片以便在下载失败时有备选 | |
| search_count = min(requirement.count * 3, 20) # 搜索3倍数量,但不超过20张 | |
| # logger.info(f"开始网络搜索,关键词: {search_query}, 搜索数量: {search_count}") | |
| network_images = await self._search_images_directly( | |
| search_query, search_count, image_config=image_config, default_provider=provider | |
| ) | |
| # logger.info(f"网络搜索返回 {len(network_images)} 张图片") | |
| # 下载网络图片到本地缓存文件夹,带重试机制 | |
| successful_downloads = 0 | |
| image_index = 0 | |
| while successful_downloads < requirement.count and image_index < len(network_images): | |
| image_data = network_images[image_index] | |
| image_index += 1 | |
| try: | |
| # 生成有意义的图片标题 | |
| meaningful_title = self._generate_meaningful_image_title(image_data, slide_title, successful_downloads + 1) | |
| # 下载图片到本地缓存,带重试机制 | |
| cached_image_info = await self._download_network_image_to_cache_with_retry(image_data, meaningful_title) | |
| if cached_image_info: | |
| slide_image = SlideImageInfo( | |
| image_id=cached_image_info['image_id'], | |
| absolute_url=cached_image_info['absolute_url'], | |
| source=ImageSource.NETWORK, | |
| purpose=requirement.purpose, | |
| content_description=requirement.description, | |
| search_keywords=search_query, | |
| alt_text=image_data.get('tags', ''), | |
| title=f"网络图片 {successful_downloads + 1}", | |
| width=image_data.get('imageWidth'), | |
| height=image_data.get('imageHeight'), | |
| format=cached_image_info.get('format', 'jpg') | |
| ) | |
| images.append(slide_image) | |
| successful_downloads += 1 | |
| logger.info(f"网络图片缓存成功: {cached_image_info['absolute_url']}") | |
| else: | |
| logger.warning(f"网络图片缓存失败,尝试下一张图片") | |
| except Exception as e: | |
| logger.error(f"处理网络图片失败: {e},尝试下一张图片") | |
| continue | |
| logger.info(f"成功获取{len(images)}张网络图片") | |
| return images | |
| except Exception as e: | |
| logger.error(f"处理网络图片失败: {e}") | |
| return images | |
| def _has_network_search_providers(self, image_config: Dict[str, Any]) -> bool: | |
| """检查是否有可用的网络搜索提供商""" | |
| return bool(self._select_network_search_provider(image_config)) | |
| async def _search_images_with_service(self, query: str, count: int) -> List[Dict[str, Any]]: | |
| """使用图片服务搜索图片""" | |
| # 创建搜索缓存键 | |
| search_key = f"{query}_{count}" | |
| # 检查缓存 | |
| async with self._search_lock: | |
| if search_key in self._search_cache: | |
| logger.debug(f"使用缓存的搜索结果: {query}") | |
| return self._search_cache[search_key] | |
| try: | |
| # 检查图片服务是否可用 | |
| if not self.image_service: | |
| logger.error("图片服务未初始化,无法使用图片服务搜索") | |
| return [] | |
| image_service = self.image_service | |
| from .image.models import ImageSearchRequest | |
| # 创建搜索请求 | |
| search_request = ImageSearchRequest( | |
| query=query, | |
| per_page=max(3, min(count * 2, 20)), # 搜索更多以便筛选,确保>=3 | |
| page=1 | |
| ) | |
| # 执行搜索 | |
| search_result = await image_service.search_images(search_request) | |
| # 转换为旧格式以兼容现有代码 | |
| images = [] | |
| for image_info in search_result.images[:count]: | |
| image_data = { | |
| 'id': image_info.image_id, | |
| 'webformatURL': image_info.original_url, | |
| 'largeImageURL': image_info.original_url, | |
| 'tags': ', '.join([tag.name for tag in (image_info.tags or [])]), | |
| 'user': image_info.author or 'Unknown', | |
| 'pageURL': image_info.source_url or '', | |
| 'imageWidth': image_info.metadata.width if image_info.metadata else 0, | |
| 'imageHeight': image_info.metadata.height if image_info.metadata else 0 | |
| } | |
| images.append(image_data) | |
| # 缓存结果 | |
| async with self._search_lock: | |
| self._search_cache[search_key] = images | |
| # 限制缓存大小,避免内存泄漏 | |
| if len(self._search_cache) > 50: | |
| # 删除最旧的缓存项 | |
| oldest_key = next(iter(self._search_cache)) | |
| del self._search_cache[oldest_key] | |
| return images | |
| except Exception as e: | |
| logger.error(f"使用图片服务搜索失败: {e}") | |
| return [] | |
| async def _process_ai_generated_images(self, requirement: ImageRequirement, project_topic: str, | |
| project_scenario: str, slide_title: str, slide_content: str, | |
| image_config: Dict[str, Any], page_number: int, total_pages: int, | |
| template_html: str = "") -> List[SlideImageInfo]: | |
| """处理AI生成图片需求""" | |
| images = [] | |
| try: | |
| if not self.image_service: | |
| logger.warning("图片服务未初始化") | |
| return images | |
| # 重新加载用户特定的图片提供者配置(从数据库读取API密钥) | |
| if self.user_id is not None: | |
| await self.image_service.reload_providers_for_user(self.user_id) | |
| logger.debug(f"已为用户 {self.user_id} 重新加载图片提供者配置") | |
| # 获取默认AI图片提供商 | |
| default_provider = self._select_ai_image_provider(image_config) | |
| logger.info(f"使用AI图片提供商: {default_provider}") | |
| # 尺寸和提示词来自一次图片规划;缺失时使用本地兜底,避免继续调用LLM。 | |
| if requirement.width and requirement.height: | |
| width, height = int(requirement.width), int(requirement.height) | |
| else: | |
| resolution_options = self._get_resolution_options(default_provider, image_config) | |
| width, height = (resolution_options[0] if resolution_options else (1792, 1024)) | |
| planned_prompts = [ | |
| self._clean_compact_text(prompt, 900) | |
| for prompt in (requirement.generation_prompts or []) | |
| if self._clean_compact_text(prompt, 900) | |
| ] | |
| # 为每张图片生成不同的提示词 | |
| for i in range(requirement.count): | |
| image_prompt = ( | |
| planned_prompts[i] | |
| if i < len(planned_prompts) | |
| else self._build_fallback_generation_prompt( | |
| slide_title, | |
| slide_content, | |
| project_topic, | |
| project_scenario, | |
| requirement, | |
| i + 1, | |
| ) | |
| ) | |
| if not image_prompt: | |
| logger.warning(f"无法生成第{i+1}张图片的提示词") | |
| continue | |
| # 创建图片生成请求 | |
| from .image.models import ImageGenerationRequest, ImageProvider | |
| # 解析提供商 | |
| provider = ImageProvider.DALLE | |
| if default_provider == 'siliconflow': | |
| provider = ImageProvider.SILICONFLOW | |
| elif default_provider == 'stable_diffusion': | |
| provider = ImageProvider.STABLE_DIFFUSION | |
| elif default_provider == 'gemini': | |
| provider = ImageProvider.GEMINI | |
| elif default_provider == 'openai_image': | |
| provider = ImageProvider.OPENAI_IMAGE | |
| elif default_provider == 'pollinations': | |
| provider = ImageProvider.POLLINATIONS | |
| generation_request = ImageGenerationRequest( | |
| prompt=image_prompt, | |
| provider=provider, | |
| width=width, | |
| height=height, | |
| quality="standard" | |
| ) | |
| # 生成图片 | |
| result = await self.image_service.generate_image(generation_request) | |
| if result.success and result.image_info: | |
| from .url_service import build_image_url | |
| absolute_url = build_image_url( | |
| result.image_info.image_id, | |
| width=result.image_info.metadata.width, | |
| height=result.image_info.metadata.height, | |
| ) | |
| slide_image = SlideImageInfo( | |
| image_id=result.image_info.image_id, | |
| absolute_url=absolute_url, | |
| source=ImageSource.AI_GENERATED, | |
| purpose=requirement.purpose, | |
| content_description=requirement.description, | |
| generation_prompt=image_prompt, | |
| alt_text=f"AI生成图片 {i+1}", | |
| title=f"AI生成图片 {i+1}", | |
| width=width, | |
| height=height, | |
| format=getattr(result.image_info, 'format', 'png') | |
| ) | |
| images.append(slide_image) | |
| logger.info(f"AI生成第{i+1}张图片成功: {absolute_url}") | |
| else: | |
| logger.error(f"AI生成第{i+1}张图片失败: {result.message}") | |
| logger.info(f"成功生成{len(images)}张AI图片") | |
| return images | |
| except Exception as e: | |
| logger.error(f"处理AI生成图片失败: {e}") | |
| return images | |
| async def _search_multiple_local_images(self, keywords: str, count: int) -> List[str]: | |
| """搜索多张本地图片""" | |
| try: | |
| if not self.image_service: | |
| return [] | |
| # 获取所有本地图片 | |
| gallery_result = await self.image_service.list_cached_images(page=1, per_page=100) | |
| if not gallery_result.get('images'): | |
| return [] | |
| # 将关键词分割成列表 | |
| keyword_list = keywords.lower().split() | |
| # 计算所有图片的匹配分数 | |
| scored_images = [] | |
| for img in gallery_result['images']: | |
| score = self._calculate_image_match_score(img, keyword_list) | |
| if score > 0: | |
| scored_images.append((img.get('image_id'), score)) | |
| # 按分数排序并选择前N张 | |
| scored_images.sort(key=lambda x: x[1], reverse=True) | |
| selected_images = [img_id for img_id, _ in scored_images[:count]] | |
| logger.info(f"从{len(gallery_result['images'])}张本地图片中选择了{len(selected_images)}张") | |
| return selected_images | |
| except Exception as e: | |
| logger.error(f"搜索多张本地图片失败: {e}") | |
| return [] | |
| async def _search_images_directly( | |
| self, | |
| query: str, | |
| count: int, | |
| *, | |
| image_config: Optional[Dict[str, Any]] = None, | |
| default_provider: Optional[str] = None, | |
| ) -> List[Dict[str, Any]]: | |
| """使用配置的默认网络搜索提供商搜索图片""" | |
| # 创建搜索缓存键 | |
| search_key = f"direct_{query}_{count}" | |
| # 检查缓存 | |
| async with self._search_lock: | |
| if search_key in self._search_cache: | |
| logger.debug(f"使用缓存的直接搜索结果: {query}") | |
| return self._search_cache[search_key] | |
| try: | |
| from .image.models import ImageSearchRequest | |
| from .image.config.image_config import ImageServiceConfig | |
| # 获取配置 | |
| config_manager = ImageServiceConfig() | |
| config = config_manager.get_config() | |
| provider_name = self._normalize_network_search_provider( | |
| default_provider | |
| or (image_config or {}).get("default_network_search_provider") | |
| or "unsplash" | |
| ) | |
| logger.debug(f"使用默认网络搜索提供商: {provider_name}") | |
| # 根据配置的默认提供商创建相应的提供者 | |
| provider = None | |
| if provider_name == 'pixabay': | |
| pixabay_config = dict(config.get('pixabay', {}) or {}) | |
| pixabay_config['api_key'] = (image_config or {}).get('pixabay_api_key') or pixabay_config.get('api_key') | |
| if not pixabay_config.get('api_key'): | |
| logger.warning("Pixabay API key not configured") | |
| return [] | |
| from .image.providers.pixabay_provider import PixabaySearchProvider | |
| provider = PixabaySearchProvider(pixabay_config) | |
| elif provider_name == 'searxng': | |
| searxng_config = dict(config.get('searxng', {}) or {}) | |
| searxng_config['host'] = (image_config or {}).get('searxng_host') or searxng_config.get('host') | |
| if not searxng_config.get('host'): | |
| logger.warning("SearXNG host not configured") | |
| return [] | |
| from .image.providers.searxng_image_provider import SearXNGSearchProvider | |
| provider = SearXNGSearchProvider(searxng_config) | |
| else: # 默认使用unsplash | |
| unsplash_config = dict(config.get('unsplash', {}) or {}) | |
| unsplash_config['api_key'] = (image_config or {}).get('unsplash_access_key') or unsplash_config.get('api_key') | |
| if not unsplash_config.get('api_key'): | |
| logger.warning("Unsplash API key not configured") | |
| return [] | |
| from .image.providers.unsplash_provider import UnsplashSearchProvider | |
| provider = UnsplashSearchProvider(unsplash_config) | |
| if not provider: | |
| logger.error("无法创建网络搜索提供商") | |
| return [] | |
| # 创建搜索请求 | |
| # 根据不同提供商调整per_page参数 | |
| if provider_name == 'pixabay': | |
| # Pixabay API 要求 per_page 范围为 3-200 | |
| per_page = max(3, min(count, 200)) | |
| else: | |
| # 其他提供商使用更宽松的限制 | |
| per_page = max(1, min(count, 50)) | |
| search_request = ImageSearchRequest( | |
| query=query, | |
| per_page=per_page, | |
| page=1 | |
| ) | |
| # 执行搜索 | |
| search_result = await provider.search(search_request) | |
| # 转换为旧格式以兼容现有代码 | |
| images = [] | |
| if search_result and search_result.images: | |
| for image_info in search_result.images[:count]: | |
| image_data = { | |
| 'id': image_info.image_id, | |
| 'webformatURL': image_info.original_url, | |
| 'largeImageURL': image_info.original_url, | |
| 'tags': ', '.join([tag.name for tag in (image_info.tags or [])]), | |
| 'user': image_info.author or 'Unknown', | |
| 'pageURL': image_info.source_url or '', | |
| 'imageWidth': image_info.metadata.width if image_info.metadata else 0, | |
| 'imageHeight': image_info.metadata.height if image_info.metadata else 0 | |
| } | |
| images.append(image_data) | |
| logger.debug(f"转换图片{len(images)}: {image_info.title[:50] if image_info.title else 'N/A'}...") | |
| # 缓存结果 | |
| async with self._search_lock: | |
| self._search_cache[search_key] = images | |
| # 限制缓存大小 | |
| if len(self._search_cache) > 50: | |
| oldest_key = next(iter(self._search_cache)) | |
| del self._search_cache[oldest_key] | |
| logger.info(f"直接搜索获得{len(images)}张图片: {query}") | |
| return images | |
| except Exception as e: | |
| logger.error(f"直接搜索失败: {e}") | |
| import traceback | |
| logger.error(f"搜索异常详情: {traceback.format_exc()}") | |
| return [] | |
| async def _download_network_image_to_cache(self, image_data: Dict[str, Any], title: str) -> Optional[Dict[str, Any]]: | |
| """下载网络图片并上传到图床系统""" | |
| try: | |
| # 检查图片服务是否可用 | |
| if not self.image_service: | |
| logger.error("图片服务未初始化,无法下载网络图片到缓存") | |
| return None | |
| # 获取图片URL | |
| image_url = (image_data.get('webformatURL') or | |
| image_data.get('url') or | |
| image_data.get('largeImageURL') or | |
| image_data.get('original_url')) | |
| if not image_url: | |
| logger.warning(f"网络图片URL为空,图片数据: {image_data}") | |
| return None | |
| # 下载图片数据 | |
| async with aiohttp.ClientSession() as session: | |
| async with session.get(image_url) as response: | |
| if response.status == 200: | |
| image_data_bytes = await response.read() | |
| # 获取文件扩展名 | |
| content_type = response.headers.get('content-type', 'image/jpeg') | |
| if 'jpeg' in content_type or 'jpg' in content_type: | |
| file_extension = 'jpg' | |
| elif 'png' in content_type: | |
| file_extension = 'png' | |
| elif 'webp' in content_type: | |
| file_extension = 'webp' | |
| else: | |
| file_extension = 'jpg' # 默认 | |
| # 创建上传请求 | |
| from .image.models import ImageUploadRequest | |
| # 生成更好的描述和标签 | |
| description, tags = self._generate_image_metadata(image_data, title) | |
| upload_request = ImageUploadRequest( | |
| filename=f"{title}.{file_extension}", | |
| content_type=content_type, | |
| file_size=len(image_data_bytes), | |
| title=title, | |
| description=description, | |
| tags=tags, | |
| category="network_search", | |
| source_type=ImageSourceType.WEB_SEARCH, | |
| original_url=image_url | |
| ) | |
| # 上传到图床系统 | |
| result = await self.image_service.upload_image(upload_request, image_data_bytes) | |
| if result.success and result.image_info: | |
| # 构建图床API的绝对URL | |
| from .url_service import build_image_url | |
| absolute_url = build_image_url( | |
| result.image_info.image_id, | |
| width=result.image_info.metadata.width, | |
| height=result.image_info.metadata.height, | |
| ) | |
| return { | |
| 'image_id': result.image_info.image_id, | |
| 'absolute_url': absolute_url, | |
| 'format': result.image_info.metadata.format.value, | |
| 'width': result.image_info.metadata.width, | |
| 'height': result.image_info.metadata.height | |
| } | |
| else: | |
| logger.error(f"上传网络图片到图床失败: {result.message}") | |
| return None | |
| else: | |
| logger.error(f"下载网络图片失败,状态码: {response.status}") | |
| return None | |
| except Exception as e: | |
| logger.error(f"下载网络图片到图床失败: {e}") | |
| return None | |
| async def _download_network_image_to_cache_with_retry(self, image_data: Dict[str, Any], title: str, max_retries: int = 3) -> Optional[Dict[str, Any]]: | |
| """下载网络图片并上传到图床系统,带重试机制""" | |
| for attempt in range(max_retries): | |
| try: | |
| result = await self._download_network_image_to_cache(image_data, title) | |
| if result: | |
| return result | |
| else: | |
| logger.warning(f"第{attempt + 1}次下载网络图片失败,准备重试") | |
| if attempt < max_retries - 1: | |
| # 等待一段时间后重试 | |
| await asyncio.sleep(1 * (attempt + 1)) # 递增等待时间 | |
| continue | |
| except Exception as e: | |
| logger.warning(f"第{attempt + 1}次下载网络图片异常: {e}") | |
| if attempt < max_retries - 1: | |
| await asyncio.sleep(1 * (attempt + 1)) # 递增等待时间 | |
| continue | |
| else: | |
| logger.error(f"所有{max_retries}次下载尝试都失败") | |
| return None | |
| def _generate_meaningful_image_title(self, image_data: Dict[str, Any], slide_title: str, index: int) -> str: | |
| """生成有意义的图片标题""" | |
| try: | |
| # 获取图片标签或描述 | |
| tags = image_data.get('tags', '') | |
| description = image_data.get('description', '') | |
| # 清理幻灯片标题,移除特殊字符 | |
| clean_slide_title = ''.join(c for c in slide_title if c.isalnum() or c in ' -_') | |
| clean_slide_title = clean_slide_title.strip().replace(' ', '_') | |
| # 如果有标签,使用前2个标签 | |
| if tags: | |
| if isinstance(tags, str): | |
| tag_list = [tag.strip() for tag in tags.split(',')[:2] if tag.strip()] | |
| elif isinstance(tags, list): | |
| tag_list = [str(tag).strip() for tag in tags[:2] if str(tag).strip()] | |
| else: | |
| tag_list = [] | |
| if tag_list: | |
| # 清理标签 | |
| clean_tags = [] | |
| for tag in tag_list: | |
| clean_tag = ''.join(c for c in tag if c.isalnum() or c in ' -_') | |
| clean_tag = clean_tag.strip().replace(' ', '_') | |
| if clean_tag and len(clean_tag) > 1: | |
| clean_tags.append(clean_tag) | |
| if clean_tags: | |
| tags_part = '_'.join(clean_tags) | |
| # 组合:幻灯片标题_标签_序号 | |
| if clean_slide_title: | |
| title = f"{clean_slide_title}_{tags_part}_{index}" | |
| else: | |
| title = f"slide_{tags_part}_{index}" | |
| # 限制长度 | |
| max_length = 60 | |
| if len(title) > max_length: | |
| title = title[:max_length].rstrip('_') | |
| return title | |
| # 如果有描述但没有标签 | |
| if description: | |
| clean_desc = ''.join(c for c in description[:20] if c.isalnum() or c in ' -_') | |
| clean_desc = clean_desc.strip().replace(' ', '_') | |
| if clean_desc: | |
| if clean_slide_title: | |
| return f"{clean_slide_title}_{clean_desc}_{index}" | |
| else: | |
| return f"slide_{clean_desc}_{index}" | |
| # 默认命名 | |
| if clean_slide_title: | |
| return f"{clean_slide_title}_image_{index}" | |
| else: | |
| return f"slide_image_{index}" | |
| except Exception as e: | |
| logger.warning(f"生成有意义图片标题失败: {e}") | |
| # 回退到简单命名 | |
| return f"slide_image_{index}" | |
| def _generate_image_metadata(self, image_data: Dict[str, Any], title: str) -> tuple[str, list]: | |
| """生成图片的描述和标签""" | |
| try: | |
| # 获取图片来源信息 | |
| source_info = "" | |
| if 'user' in image_data: | |
| source_info = f"作者: {image_data['user']}" | |
| elif 'author' in image_data: | |
| source_info = f"作者: {image_data['author']}" | |
| # 获取图片统计信息 | |
| stats_info = "" | |
| if 'views' in image_data or 'downloads' in image_data or 'likes' in image_data: | |
| stats = [] | |
| if 'views' in image_data: | |
| stats.append(f"浏览: {image_data['views']}") | |
| if 'downloads' in image_data: | |
| stats.append(f"下载: {image_data['downloads']}") | |
| if 'likes' in image_data: | |
| stats.append(f"点赞: {image_data['likes']}") | |
| stats_info = " | ".join(stats) | |
| # 获取尺寸信息 | |
| size_info = "" | |
| width = image_data.get('webformatWidth') or image_data.get('imageWidth') or image_data.get('width') | |
| height = image_data.get('webformatHeight') or image_data.get('imageHeight') or image_data.get('height') | |
| if width and height: | |
| size_info = f"尺寸: {width}x{height}" | |
| # 组合描述 | |
| description_parts = [f"网络搜索图片: {title}"] | |
| if source_info: | |
| description_parts.append(source_info) | |
| if size_info: | |
| description_parts.append(size_info) | |
| if stats_info: | |
| description_parts.append(stats_info) | |
| description = " | ".join(description_parts) | |
| # 处理标签 | |
| tags = [] | |
| raw_tags = image_data.get('tags', '') | |
| if raw_tags: | |
| if isinstance(raw_tags, str): | |
| # 分割字符串标签 | |
| tag_list = [tag.strip() for tag in raw_tags.replace(',', ' ').split() if tag.strip()] | |
| elif isinstance(raw_tags, list): | |
| # 处理列表标签 | |
| tag_list = [str(tag).strip() for tag in raw_tags if str(tag).strip()] | |
| else: | |
| tag_list = [] | |
| # 清理和去重标签 | |
| seen_tags = set() | |
| for tag in tag_list: | |
| clean_tag = tag.lower().strip() | |
| if clean_tag and len(clean_tag) > 1 and clean_tag not in seen_tags: | |
| seen_tags.add(clean_tag) | |
| tags.append(clean_tag) | |
| # 限制标签数量 | |
| tags = tags[:10] | |
| # 添加默认标签 | |
| default_tags = ['网络图片', 'ppt素材'] | |
| for default_tag in default_tags: | |
| if default_tag not in tags: | |
| tags.append(default_tag) | |
| return description, tags | |
| except Exception as e: | |
| logger.warning(f"生成图片元数据失败: {e}") | |
| # 回退到简单元数据 | |
| simple_description = f"网络搜索图片: {title}" | |
| simple_tags = ['网络图片', 'ppt素材'] | |
| return simple_description, simple_tags | |
| async def _get_local_image_details(self, image_id: str) -> Dict[str, Any]: | |
| """获取本地图片详细信息""" | |
| try: | |
| if not self.image_service: | |
| return {} | |
| # 这里可以调用图片服务的方法获取详细信息 | |
| # 暂时返回基本信息 | |
| return { | |
| 'title': f'本地图片 {image_id}', | |
| 'width': None, | |
| 'height': None, | |
| 'file_size': None, | |
| 'format': None | |
| } | |
| except Exception as e: | |
| logger.error(f"获取本地图片详细信息失败: {e}") | |
| return {} | |
| def _calculate_image_match_score(self, img: Dict[str, Any], keyword_list: List[str]) -> int: | |
| """计算图片匹配分数""" | |
| score = 0 | |
| # 处理标题、文件名、标签 | |
| title = (img.get('title') or '').lower() | |
| filename = (img.get('filename') or '').lower() | |
| tags = img.get('tags', []) | |
| if tags and len(tags) > 0 and hasattr(tags[0], 'name'): | |
| tag_names = [tag.name.lower() for tag in tags] | |
| else: | |
| tag_names = [str(tag).lower() for tag in tags if tag] | |
| # 标题匹配(权重最高) | |
| for keyword in keyword_list: | |
| if keyword in title: | |
| score += 3 | |
| # 标签匹配(权重中等) | |
| for keyword in keyword_list: | |
| for tag in tag_names: | |
| if keyword in tag or tag in keyword: | |
| score += 2 | |
| break # 每个关键词只匹配一次 | |
| # 文件名匹配(权重较低) | |
| for keyword in keyword_list: | |
| if keyword in filename: | |
| score += 1 | |
| return score | |
| async def _ai_generate_local_search_keywords(self, slide_title: str, slide_content: str, | |
| project_topic: str, project_scenario: str, | |
| requirement: ImageRequirement = None) -> Optional[str]: | |
| """使用AI生成本地图片搜索关键词""" | |
| try: | |
| # 构建需求信息 | |
| requirement_info = "" | |
| if requirement: | |
| requirement_info = f""" | |
| 图片需求信息: | |
| - 用途:{requirement.purpose.value} | |
| - 描述:{requirement.description} | |
| - 优先级:{requirement.priority} | |
| """ | |
| prompt = f"""作为专业的图片搜索专家,请为以下PPT幻灯片生成本地图片搜索关键词。 | |
| 项目主题:{project_topic} | |
| 项目场景:{project_scenario} | |
| 幻灯片标题:{slide_title} | |
| 幻灯片内容:{slide_content} | |
| {requirement_info} | |
| 要求: | |
| 1. 生成3-5个中英文关键词,用空格分隔 | |
| 2. 关键词要准确描述所需图片的内容和主题 | |
| 3. 考虑项目场景和图片用途,选择合适的图片风格 | |
| 4. 优先选择具体的视觉元素和概念 | |
| 5. 适合在本地图片库中进行标题、描述、标签匹配 | |
| 示例格式:商务 会议 图表 business chart | |
| 请只回复关键词,不要其他内容:""" | |
| response = await self._text_completion( | |
| prompt=prompt, | |
| temperature=0.5 | |
| ) | |
| search_keywords = response.content.strip() | |
| logger.info(f"AI生成本地搜索关键词: {search_keywords}") | |
| return search_keywords | |
| except Exception as e: | |
| logger.error(f"AI生成本地搜索关键词失败: {e}") | |
| return None | |
| async def _search_local_images_by_keywords(self, keywords: str) -> Optional[str]: | |
| """使用关键词搜索本地图片,返回相关度最高的图片ID""" | |
| try: | |
| if not self.image_service: | |
| logger.warning("图片服务未初始化") | |
| return None | |
| # 将关键词分割成列表 | |
| keyword_list = keywords.lower().split() | |
| # 获取所有本地图片 | |
| gallery_result = await self.image_service.list_cached_images(page=1, per_page=100) | |
| if not gallery_result.get('images'): | |
| return None | |
| best_match = None | |
| best_score = 0 | |
| for img in gallery_result['images']: | |
| score = 0 | |
| image_id = img.get('image_id') | |
| # 计算匹配分数 | |
| title = (img.get('title') or '').lower() | |
| filename = (img.get('filename') or '').lower() | |
| # 处理标签 | |
| tags = img.get('tags', []) | |
| if tags and len(tags) > 0 and hasattr(tags[0], 'name'): | |
| tag_names = [tag.name.lower() for tag in tags] | |
| else: | |
| tag_names = [str(tag).lower() for tag in tags if tag] | |
| # 标题匹配(权重最高) | |
| title_matches = 0 | |
| for keyword in keyword_list: | |
| if keyword in title: | |
| score += 3 | |
| title_matches += 1 | |
| # 标签匹配(权重中等) | |
| tag_matches = 0 | |
| for keyword in keyword_list: | |
| for tag in tag_names: | |
| if keyword in tag or tag in keyword: | |
| score += 2 | |
| tag_matches += 1 | |
| break # 每个关键词只匹配一次 | |
| # 文件名匹配(权重较低) | |
| filename_matches = 0 | |
| for keyword in keyword_list: | |
| if keyword in filename: | |
| score += 1 | |
| filename_matches += 1 | |
| # 记录详细匹配信息 | |
| if score > 0: | |
| logger.debug(f"图片 {image_id} 匹配分数: {score} (标题:{title_matches}, 标签:{tag_matches}, 文件名:{filename_matches})") | |
| # 更新最佳匹配 | |
| if score > best_score: | |
| best_score = score | |
| best_match = image_id | |
| logger.debug(f"更新最佳匹配: {best_match}, 新分数: {best_score}") | |
| if best_match and best_score > 0: | |
| logger.info(f"找到最佳匹配图片: {best_match}, 分数: {best_score}") | |
| return best_match | |
| else: | |
| logger.info("未找到匹配的本地图片") | |
| return None | |
| except Exception as e: | |
| logger.error(f"本地图片搜索失败: {e}") | |
| return None | |
| def _truncate_search_query(self, query: str, max_length: int = 100) -> str: | |
| """截断搜索查询以符合API限制,保持单词完整性""" | |
| if not query or len(query) <= max_length: | |
| return query | |
| # 在最大长度内找到最后一个空格 | |
| truncated = query[:max_length] | |
| last_space = truncated.rfind(' ') | |
| if last_space > 0: | |
| # 在最后一个空格处截断,保持单词完整 | |
| return truncated[:last_space] | |
| else: | |
| # 如果没有空格,直接截断 | |
| return truncated | |
| async def _ai_generate_search_query(self, slide_title: str, slide_content: str, | |
| project_topic: str, project_scenario: str, | |
| requirement: ImageRequirement = None, | |
| default_provider: Optional[str] = None) -> Optional[str]: | |
| """使用AI生成网络搜索关键词""" | |
| try: | |
| # 检测项目语言 | |
| project_language = self._detect_project_language(project_topic, slide_title, slide_content) | |
| # 构建需求信息 | |
| requirement_info = "" | |
| if requirement: | |
| requirement_info = f""" | |
| 图片需求信息: | |
| - 用途:{requirement.purpose.value} | |
| - 描述:{requirement.description} | |
| - 优先级:{requirement.priority} | |
| """ | |
| # 根据项目语言生成不同的提示词 | |
| if project_language == "zh": | |
| language_instruction = "中文关键词" | |
| example_format = "商务 会议 演示 图表" | |
| search_instruction = "生成3-5个中文关键词,用空格分隔" | |
| else: | |
| language_instruction = "英文关键词" | |
| example_format = "business meeting presentation chart" | |
| search_instruction = "生成3-5个英文关键词,用空格分隔" | |
| prompt = f"""作为专业的图片搜索专家,请为以下PPT幻灯片生成最佳的{language_instruction}。 | |
| 项目主题:{project_topic} | |
| 项目场景:{project_scenario} | |
| 幻灯片标题:{slide_title} | |
| 幻灯片内容:{slide_content} | |
| {requirement_info} | |
| 要求: | |
| 1. {search_instruction},总长度不超过80个字符 | |
| 2. 关键词要准确描述所需图片的内容和用途 | |
| 3. 考虑项目场景和图片用途,选择合适的图片风格 | |
| 4. 避免过于抽象的词汇,优先选择具体的视觉元素 | |
| 5. 确保关键词适合在网络图片库中搜索 | |
| 示例格式:{example_format} | |
| 请只回复关键词,不要其他内容:""" | |
| response = await self._text_completion( | |
| prompt=prompt, | |
| temperature=0.5 | |
| ) | |
| search_query = response.content.strip() | |
| # 根据不同提供商截断查询 | |
| provider_name = self._normalize_network_search_provider(default_provider) or "unsplash" | |
| # Pixabay API的100字符限制,其他提供商使用更宽松的限制 | |
| max_length = 100 if provider_name == 'pixabay' else 200 | |
| truncated_query = self._truncate_search_query(search_query, max_length) | |
| if len(search_query) > max_length: | |
| logger.warning(f"搜索关键词过长,已截断: '{search_query}' -> '{truncated_query}'") | |
| # logger.info(f"AI生成搜索关键词: {truncated_query}") | |
| return truncated_query | |
| except Exception as e: | |
| logger.error(f"AI生成搜索关键词失败: {e}") | |
| return None | |
| def _detect_project_language(self, project_topic: str, slide_title: str, slide_content: str) -> str: | |
| """检测项目语言""" | |
| # 合并所有文本内容 | |
| combined_text = f"{project_topic} {slide_title} {slide_content}" | |
| # 检查是否包含中文字符 | |
| chinese_pattern = r'[\u4e00-\u9fff]' | |
| if re.search(chinese_pattern, combined_text): | |
| return "zh" | |
| else: | |
| return "en" | |
| def _normalize_resolution_value(self, value: Any) -> Optional[tuple]: | |
| """将尺寸值规范化为(width, height)元组""" | |
| if isinstance(value, str): | |
| match = re.match(r"(\d+)\s*[x×]\s*(\d+)", value.strip()) | |
| if match: | |
| try: | |
| return int(match.group(1)), int(match.group(2)) | |
| except (TypeError, ValueError): | |
| return None | |
| if isinstance(value, dict): | |
| width = value.get('width') or value.get('w') | |
| height = value.get('height') or value.get('h') | |
| try: | |
| if width and height: | |
| return int(width), int(height) | |
| except (TypeError, ValueError): | |
| return None | |
| if isinstance(value, (list, tuple)) and len(value) >= 2: | |
| try: | |
| return int(value[0]), int(value[1]) | |
| except (TypeError, ValueError): | |
| return None | |
| return None | |
| def _get_resolution_options(self, provider: str, image_config: Dict[str, Any]) -> List[tuple]: | |
| """获取指定提供商的可用分辨率列表(已去重、按配置优先顺序)""" | |
| provider_key = (provider or image_config.get('default_ai_image_provider') or 'dalle').lower() | |
| default_presets = { | |
| 'dalle': ["1792x1024", "1024x1792", "1024x1024"], | |
| 'openai_image': ["1536x1024", "1024x1536", "1024x1024"], | |
| 'siliconflow': ["1024x1024", "1024x2048", "1536x1024", "2048x1152", "1152x2048"], | |
| 'gemini': ["1024x1024", "1344x768", "768x1344"], | |
| 'pollinations': ["1024x1024", "1344x768", "768x1344", "1536x1024", "1024x1536"], | |
| 'default': ["1792x1024", "1024x1792", "1024x1024"] | |
| } | |
| presets = image_config.get('ai_image_resolution_presets') | |
| parsed_presets = {} | |
| if isinstance(presets, str) and presets.strip(): | |
| try: | |
| parsed_presets = json.loads(presets) | |
| except Exception as e: | |
| logger.warning(f"Failed to parse ai_image_resolution_presets: {e}") | |
| elif isinstance(presets, dict): | |
| parsed_presets = presets | |
| options: List[tuple] = [] | |
| provider_presets = parsed_presets.get(provider_key) if isinstance(parsed_presets, dict) else None | |
| if provider_presets: | |
| if not isinstance(provider_presets, list): | |
| provider_presets = [provider_presets] | |
| for value in provider_presets: | |
| normalized = self._normalize_resolution_value(value) | |
| if normalized: | |
| options.append(normalized) | |
| # 如果没有自定义预设,使用默认预设 | |
| if not options: | |
| fallback_presets = default_presets.get(provider_key) or default_presets['default'] | |
| for value in fallback_presets: | |
| normalized = self._normalize_resolution_value(value) | |
| if normalized: | |
| options.append(normalized) | |
| # 允许从单值配置中注入优先尺寸(如dalle_image_size),添加到列表开头 | |
| provider_size_keys = { | |
| 'dalle': 'dalle_image_size', | |
| 'siliconflow': 'siliconflow_image_size', | |
| } | |
| size_key = provider_size_keys.get(provider_key) | |
| if size_key and image_config.get(size_key): | |
| normalized_size = self._normalize_resolution_value(image_config.get(size_key)) | |
| if normalized_size and normalized_size not in options: | |
| options.insert(0, normalized_size) | |
| # 去重并保持顺序 | |
| unique_options = [] | |
| for opt in options: | |
| if opt not in unique_options: | |
| unique_options.append(opt) | |
| return unique_options | |
| async def _ai_decide_image_dimensions(self, slide_title: str, slide_content: str, | |
| project_topic: str, project_scenario: str, | |
| requirement: ImageRequirement = None, | |
| provider: Optional[str] = None, | |
| image_config: Optional[Dict[str, Any]] = None) -> tuple: | |
| """使用AI决定图片的最佳尺寸""" | |
| try: | |
| config = image_config or {} | |
| provider_key = (provider or config.get('default_ai_image_provider') or 'dalle').lower() | |
| available_dimensions = self._get_resolution_options(provider_key, config) | |
| if not available_dimensions: | |
| available_dimensions = [(1792, 1024), (1024, 1792), (1024, 1024)] | |
| # 限制选项数量,避免提示过长 | |
| if len(available_dimensions) > 6: | |
| available_dimensions = available_dimensions[:6] | |
| if len(available_dimensions) == 1: | |
| selected_dimensions = available_dimensions[0] | |
| logger.info(f"仅有一个可用尺寸,直接使用: {selected_dimensions[0]}x{selected_dimensions[1]} (提供商: {provider_key})") | |
| return selected_dimensions | |
| # 构建需求信息 | |
| requirement_info = "" | |
| if requirement: | |
| requirement_info = f""" | |
| 图片需求信息: | |
| - 用途:{requirement.purpose.value} | |
| - 描述:{requirement.description} | |
| - 优先级:{requirement.priority} | |
| """ | |
| option_lines = [] | |
| for idx, (w, h) in enumerate(available_dimensions, start=1): | |
| aspect = w / h | |
| if aspect > 1.1: | |
| orientation = "横向" | |
| use_case = "横向展示、背景或宽屏内容" | |
| elif aspect < 0.9: | |
| orientation = "竖向" | |
| use_case = "人物肖像、竖版海报或移动端展示" | |
| else: | |
| orientation = "正方形" | |
| use_case = "产品展示、图标或社交媒体" | |
| option_lines.append(f"{idx}. {w}x{h} ({orientation},适合{use_case})") | |
| prompt = f"""作为专业的PPT设计师,请根据以下信息为图片选择最佳的尺寸规格。 | |
| 项目信息: | |
| - 主题:{project_topic} | |
| - 场景:{project_scenario} | |
| 幻灯片信息: | |
| - 标题:{slide_title} | |
| - 内容:{slide_content} | |
| {requirement_info} | |
| 可选尺寸规格: | |
| 当前图片提供商:{provider_key} | |
| {chr(10).join(option_lines)} | |
| 请根据内容特点、用途和展示效果选择最合适的尺寸。 | |
| 要求: | |
| 1. 考虑内容的视觉特点(横向/竖向/方形更适合) | |
| 2. 考虑图片用途(背景/装饰/说明/图标等) | |
| 3. 考虑PPT演示的整体效果 | |
| 4. 只回复对应的数字编号或尺寸值(如 1792x1024),不要其他内容""" | |
| response = await self._text_completion( | |
| prompt=prompt, | |
| temperature=0.3 | |
| ) | |
| choice_text = response.content.strip() | |
| # 先尝试解析显式的尺寸值 | |
| selected_dimensions = available_dimensions[0] | |
| size_match = re.search(r"(\d+)\s*[x×]\s*(\d+)", choice_text) | |
| if size_match: | |
| candidate = (int(size_match.group(1)), int(size_match.group(2))) | |
| for dims in available_dimensions: | |
| if dims == candidate: | |
| selected_dimensions = dims | |
| break | |
| # 如果未匹配到尺寸值,再尝试编号 | |
| if selected_dimensions == available_dimensions[0]: | |
| index_match = re.search(r"(\d+)", choice_text) | |
| if index_match: | |
| idx = int(index_match.group(1)) | |
| if 1 <= idx <= len(available_dimensions): | |
| selected_dimensions = available_dimensions[idx - 1] | |
| logger.info(f"AI选择图片尺寸: {selected_dimensions[0]}x{selected_dimensions[1]} (响应: {choice_text}, 提供商: {provider_key})") | |
| return selected_dimensions | |
| except Exception as e: | |
| logger.error(f"AI决定图片尺寸失败: {e}") | |
| fallback = available_dimensions[0] if 'available_dimensions' in locals() and available_dimensions else (1792, 1024) | |
| return fallback # 默认尺寸 | |
| async def _ai_generate_image_prompt(self, slide_title: str, slide_content: str, project_topic: str, | |
| project_scenario: str, page_number: int, total_pages: int, | |
| template_html: str = "", requirement: ImageRequirement = None, | |
| image_index: int = 1) -> Optional[str]: | |
| """使用AI生成图片生成提示词""" | |
| try: | |
| # 构建包含模板HTML的提示词 | |
| template_context = "" | |
| if template_html.strip(): | |
| template_excerpt = strip_base64_image_payloads_for_prompt(template_html)[:500] | |
| template_context = f""" | |
| 当前PPT模板HTML参考: | |
| {template_excerpt}... | |
| """ | |
| # 构建需求信息 | |
| requirement_info = "" | |
| if requirement: | |
| requirement_info = f""" | |
| 图片需求信息: | |
| - 用途:{requirement.purpose.value} | |
| - 描述:{requirement.description} | |
| - 优先级:{requirement.priority} | |
| - 当前是第{image_index}张图片 | |
| """ | |
| prompt = f"""作为专业的AI图片生成提示词专家,请为以下PPT幻灯片生成高质量的英文图片生成提示词。 | |
| 项目信息: | |
| - 主题:{project_topic} | |
| - 场景:{project_scenario} | |
| - 当前页:{page_number}/{total_pages} | |
| 幻灯片信息: | |
| - 标题:{slide_title} | |
| - 内容:{slide_content} | |
| {requirement_info} | |
| {template_context} | |
| 要求: | |
| 1. 生成详细的英文提示词,描述所需图片的视觉内容 | |
| 2. 根据项目场景、图片用途和模板风格选择合适的风格 | |
| 3. 包含具体的视觉元素描述,确保与模板风格协调 | |
| 4. 确保图片适合PPT演示使用,符合指定用途 | |
| 5. 考虑16:9或4:3的横向构图 | |
| 6. 避免包含文字内容 | |
| 7. 如果是多张图片中的一张,确保风格一致但内容有所区别 | |
| 风格指导: | |
| - business: professional, clean, modern office, corporate style | |
| - technology: futuristic, digital, high-tech, innovation | |
| - education: clear, informative, academic, learning environment | |
| - general: clean, modern, professional presentation style | |
| 用途指导: | |
| - decoration: 装饰性,美观、和谐、不抢夺主要内容焦点 | |
| - illustration: 说明性,直观、清晰、辅助理解内容 | |
| - background: 背景性,淡雅、不干扰前景内容 | |
| - icon: 图标性,简洁、符号化、易识别 | |
| - chart_support: 图表辅助,数据可视化、专业、清晰 | |
| - content_visual: 内容可视化,概念具象化、生动、准确 | |
| 请生成一个完整的英文提示词(不超过120词),直接输出提示词,不要添加任何其他内容""" | |
| response = await self._text_completion( | |
| prompt=prompt, | |
| temperature=0.7 | |
| ) | |
| image_prompt = response.content.strip() | |
| logger.info(f"AI生成第{image_index}张图片提示词: {image_prompt}") | |
| return image_prompt | |
| except Exception as e: | |
| logger.error(f"AI生成图片提示词失败: {e}") | |
| return None | |
| async def _ai_should_add_image(self, slide_data: Dict[str, Any], project_topic: str, | |
| project_scenario: str, page_number: int, total_pages: int) -> bool: | |
| """使用AI判断该页是否需要或适合插入图片""" | |
| try: | |
| # 提取幻灯片内容信息 | |
| slide_title = slide_data.get('title', '') | |
| slide_content = slide_data.get('content_points', []) | |
| slide_content_text = '\n'.join(slide_content) if isinstance(slide_content, list) else str(slide_content) | |
| content_length = len(slide_content_text.strip()) | |
| content_points_count = len(slide_content) if isinstance(slide_content, list) else 0 | |
| prompt = f"""作为专业的PPT设计师,请根据以下标准判断该幻灯片是否需要插入配图: | |
| 【项目信息】 | |
| - 主题:{project_topic} | |
| - 场景:{project_scenario} | |
| - 当前页:{page_number}/{total_pages} | |
| 【幻灯片内容】 | |
| - 标题:{slide_title} | |
| - 内容要点数量:{content_points_count}个 | |
| - 内容字数:{content_length}字 | |
| - 具体内容: | |
| {slide_content_text} | |
| 【判断标准】 | |
| 请综合考虑以下因素: | |
| 1. 内容丰富程度: | |
| - 内容过少(<50字或<3个要点):建议添加图片增强视觉效果 | |
| - 内容适中(50-200字,3-6个要点):根据内容性质判断 | |
| - 内容丰富(>200字或>6个要点):通常不需要额外图片 | |
| 2. 理解难度: | |
| - 抽象概念、复杂流程、技术原理:需要图片辅助理解 | |
| - 数据统计、对比分析:适合图表或图示 | |
| - 简单陈述、常识内容:通常不需要图片 | |
| 3. 内容类型: | |
| - 封面页、章节页:通常需要装饰性图片 | |
| - 总结页、结论页:根据内容量判断 | |
| - 纯文字列表:可能需要图片平衡版面 | |
| - 已有充实内容的页面:通常不需要额外图片 | |
| 4. 视觉平衡: | |
| - 页面显得空旷:需要图片填充 | |
| - 文字密集:不建议添加图片 | |
| - 版面协调:根据整体设计需要 | |
| 请基于以上标准进行专业判断,只回复"是"或"否":""" | |
| response = await self._text_completion( | |
| prompt=prompt, | |
| temperature=0.7 | |
| ) | |
| # logger.info(f"AI判断是否需要图片的回复: {response.content}") | |
| decision = response.content.strip().lower() | |
| should_add = decision in ['是', 'yes', 'true', '需要', '适合'] | |
| logger.info(f"AI判断第{page_number}页是否需要图片: {decision} -> {should_add}") | |
| return should_add | |
| except Exception as e: | |
| logger.error(f"AI判断是否添加图片失败: {e}") | |
| # 出错时默认不添加图片,避免不必要的处理 | |
| return False | |
| async def _insert_images_into_slide(self, slide_html: str, images_collection: SlideImagesCollection, slide_title: str) -> str: | |
| """AI智能将生成的图片插入到幻灯片HTML中""" | |
| try: | |
| if not images_collection or not images_collection.images: | |
| logger.warning("没有图片需要插入") | |
| return slide_html | |
| # 准备图片信息 | |
| images_info = [] | |
| for i, image in enumerate(images_collection.images): | |
| image_info = { | |
| "index": i + 1, | |
| "url": image.absolute_url, | |
| "description": image.content_description or f"配图{i+1}", | |
| "alt_text": image.alt_text or f"配图{i+1}", | |
| "title": image.title or f"AI生成配图{i+1}", | |
| "source": image.source.value, | |
| "width": image.width, | |
| "height": image.height | |
| } | |
| images_info.append(image_info) | |
| # 构建AI提示词 | |
| prompt = f"""作为专业的网页设计师,请分析以下幻灯片HTML结构,并智能地将提供的图片融入到页面内。 | |
| 幻灯片标题:{slide_title} | |
| 当前HTML结构: | |
| ```html | |
| {slide_html} | |
| ``` | |
| 需要插入的图片信息: | |
| {images_info} | |
| 要求: | |
| - 请在HTML中合理使用这些图片资源 | |
| - 图片地址已经是绝对地址,可以直接使用 | |
| - 根据图片用途、内容描述和实际尺寸选择合适的位置和样式 | |
| - 充分利用图片的尺寸信息(宽度x高度)来优化布局设计 | |
| - 根据图片文件大小和格式选择合适的显示策略 | |
| - 确保图片与页面内容和设计风格协调 | |
| - 可以使用CSS对图片进行适当的样式调整(大小、位置、边框等) | |
| **重要输出格式要求**: | |
| - 必须使用markdown代码块格式返回HTML代码 | |
| - 格式:```html\\n[HTML代码]\\n``` | |
| - HTML代码必须以<!DOCTYPE html>开始,以</html>结束 | |
| - 不要在代码块前后添加任何解释文字 | |
| - **页眉页脚保持原样** | |
| """ | |
| # 调用AI进行智能插入。该步骤会让模型重写整页HTML,可能较慢;设置超时避免一键配图一直卡住。 | |
| logger.info( | |
| "开始AI智能插入图片到幻灯片: title=%s, image_count=%s, html_length=%s", | |
| slide_title, | |
| len(images_collection.images), | |
| len(slide_html or ""), | |
| ) | |
| try: | |
| response = await asyncio.wait_for( | |
| self._text_completion( | |
| prompt=prompt, | |
| temperature=0.3, | |
| ), | |
| timeout=45, | |
| ) | |
| logger.info("AI智能插入图片响应完成: title=%s", slide_title) | |
| except asyncio.TimeoutError: | |
| logger.warning("AI智能插入图片超时,改用默认插入逻辑: title=%s", slide_title) | |
| return await self._insert_images_with_default_logic(slide_html, images_collection, slide_title) | |
| # 提取markdown代码块中的HTML内容 | |
| updated_html = self._extract_html_from_markdown_response(response.content.strip()) | |
| if not updated_html: | |
| logger.warning("无法从AI响应中提取HTML内容,使用默认插入逻辑") | |
| return await self._insert_images_with_default_logic(slide_html, images_collection, slide_title) | |
| # 验证返回的HTML是否有效 | |
| if self._validate_html_structure(updated_html): | |
| logger.info(f"AI成功插入{len(images_collection.images)}张图片到幻灯片中") | |
| return updated_html | |
| else: | |
| logger.warning("AI返回的HTML结构无效,使用默认插入逻辑") | |
| return await self._insert_images_with_default_logic(slide_html, images_collection, slide_title) | |
| except Exception as e: | |
| logger.error(f"AI智能插入图片失败: {e}") | |
| logger.info("回退到默认插入逻辑") | |
| return await self._insert_images_with_default_logic(slide_html, images_collection, slide_title) | |
| def _extract_html_from_markdown_response(self, response_content: str) -> str: | |
| """从AI响应中提取markdown代码块中的HTML内容""" | |
| try: | |
| import re | |
| # 查找markdown代码块 ```html ... ``` | |
| html_pattern = r'```html\s*\n(.*?)\n```' | |
| match = re.search(html_pattern, response_content, re.DOTALL | re.IGNORECASE) | |
| if match: | |
| html_content = match.group(1).strip() | |
| logger.debug(f"成功提取HTML内容,长度: {len(html_content)} 字符") | |
| return html_content | |
| # 如果没找到标准格式,尝试查找其他可能的格式 | |
| # 查找 ```html ... ``` (不区分大小写) | |
| html_pattern2 = r'```(?:html|HTML)\s*\n?(.*?)\n?```' | |
| match2 = re.search(html_pattern2, response_content, re.DOTALL) | |
| if match2: | |
| html_content = match2.group(1).strip() | |
| logger.debug(f"使用备用模式提取HTML内容,长度: {len(html_content)} 字符") | |
| return html_content | |
| # 如果还是没找到,尝试查找任何代码块 | |
| code_pattern = r'```\s*\n?(.*?)\n?```' | |
| match3 = re.search(code_pattern, response_content, re.DOTALL) | |
| if match3: | |
| potential_html = match3.group(1).strip() | |
| # 检查是否看起来像HTML | |
| if ('<!DOCTYPE html>' in potential_html or | |
| '<html' in potential_html or | |
| '<div' in potential_html or | |
| '<body' in potential_html): | |
| logger.debug(f"从通用代码块中提取HTML内容,长度: {len(potential_html)} 字符") | |
| return potential_html | |
| # 最后尝试:如果响应内容本身就是HTML(没有代码块包装) | |
| if ('<!DOCTYPE html>' in response_content or | |
| '<html' in response_content or | |
| ('<div' in response_content and '</' in response_content)): | |
| logger.debug("响应内容本身就是HTML格式") | |
| return response_content.strip() | |
| logger.warning("无法从AI响应中提取HTML内容") | |
| logger.debug(f"AI响应内容预览: {response_content[:200]}...") | |
| return "" | |
| except Exception as e: | |
| logger.error(f"提取HTML内容失败: {e}") | |
| return "" | |
| def _validate_html_structure(self, html: str) -> bool: | |
| """验证HTML结构是否有效""" | |
| try: | |
| from bs4 import BeautifulSoup | |
| soup = BeautifulSoup(html, 'html.parser') | |
| # 检查基本结构 - 至少要有一个容器元素 | |
| container_elements = soup.find_all(['body', 'div', 'section', 'main', 'article']) | |
| if not container_elements: | |
| return False | |
| # 检查是否包含图片元素 | |
| img_elements = soup.find_all('img') | |
| if not img_elements: | |
| return False | |
| # 检查HTML长度是否合理(不能太短或太长) | |
| if len(html.strip()) < 50 or len(html.strip()) > 50000: | |
| return False | |
| # 检查图片元素是否有有效的src属性 | |
| valid_images = 0 | |
| for img in img_elements: | |
| src = img.get('src', '').strip() | |
| if src and (src.startswith('http') or src.startswith('/') or src.startswith('data:')): | |
| valid_images += 1 | |
| if valid_images == 0: | |
| return False | |
| return True | |
| except Exception as e: | |
| logger.error(f"HTML验证失败: {e}") | |
| return False | |
| async def _insert_images_with_default_logic(self, slide_html: str, images_collection: SlideImagesCollection, slide_title: str) -> str: | |
| """使用默认逻辑插入图片(备用方案)""" | |
| try: | |
| from bs4 import BeautifulSoup | |
| soup = BeautifulSoup(slide_html, 'html.parser') | |
| # 查找合适的插入位置 | |
| # 1. 优先查找现有的图片容器 | |
| img_containers = soup.find_all(['div', 'section'], class_=lambda x: x and any( | |
| keyword in x.lower() for keyword in ['image', 'img', 'picture', 'photo', 'visual'] | |
| )) | |
| # 2. 查找内容区域 | |
| content_areas = soup.find_all(['div', 'section'], class_=lambda x: x and any( | |
| keyword in x.lower() for keyword in ['content', 'main', 'body', 'text'] | |
| )) | |
| # 3. 查找标题后的位置 | |
| title_elements = soup.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']) | |
| inserted_count = 0 | |
| for i, image in enumerate(images_collection.images): | |
| if inserted_count >= 3: # 最多插入3张图片 | |
| break | |
| # 创建图片元素 | |
| img_element = soup.new_tag('img') | |
| img_element['src'] = image.absolute_url | |
| img_element['alt'] = image.alt_text or f"配图{i+1}" | |
| img_element['title'] = image.title or f"AI生成配图{i+1}" | |
| img_element['style'] = "max-width: 100%; height: auto; border-radius: 8px; margin: 10px 0;" | |
| # 创建图片容器 | |
| img_container = soup.new_tag('div') | |
| img_container['class'] = 'auto-generated-image-container' | |
| img_container['style'] = "text-align: center; margin: 20px 0; padding: 10px;" | |
| img_container.append(img_element) | |
| # 添加图片说明 | |
| if image.content_description: | |
| caption = soup.new_tag('p') | |
| caption['style'] = "font-size: 0.9em; color: #666; margin-top: 8px; font-style: italic;" | |
| caption.string = image.content_description | |
| img_container.append(caption) | |
| # 选择插入位置 | |
| inserted = False | |
| # 方法1: 插入到现有图片容器中 | |
| if img_containers and not inserted: | |
| target_container = img_containers[min(i, len(img_containers) - 1)] | |
| target_container.clear() | |
| target_container.append(img_container) | |
| inserted = True | |
| logger.info(f"图片{i+1}插入到现有图片容器中") | |
| # 方法2: 插入到内容区域 | |
| elif content_areas and not inserted: | |
| target_area = content_areas[0] | |
| # 在内容区域的末尾插入 | |
| target_area.append(img_container) | |
| inserted = True | |
| logger.info(f"图片{i+1}插入到内容区域") | |
| # 方法3: 插入到标题后 | |
| elif title_elements and not inserted: | |
| title_element = title_elements[0] | |
| title_element.insert_after(img_container) | |
| inserted = True | |
| logger.info(f"图片{i+1}插入到标题后") | |
| # 方法4: 插入到body末尾 | |
| elif not inserted: | |
| body = soup.find('body') | |
| if body: | |
| body.append(img_container) | |
| inserted = True | |
| logger.info(f"图片{i+1}插入到body末尾") | |
| if inserted: | |
| inserted_count += 1 | |
| logger.info(f"默认逻辑成功插入{inserted_count}张图片到幻灯片中") | |
| return str(soup) | |
| except Exception as e: | |
| logger.error(f"默认插入图片逻辑失败: {e}") | |
| return slide_html | |