""" 图片元数据服务 负责管理生成图片的元数据,支持绑定到角色/聊天的特定楼层 数据持久化到 data/image_metadata 目录 """ import json import uuid from pathlib import Path from typing import List, Dict, Optional, Any from datetime import datetime try: from backend.models.internal import ImageMetadata from backend.core.config import settings except ImportError: from models.internal import ImageMetadata from core.config import settings class ImageMetadataService: """ 图片元数据服务 功能: - 记录生成图片的元数据 - 按角色/聊天/楼层组织 - 支持 swipe(同一楼层多张图片) - 提供画廊查询接口 """ def __init__(self): self.metadata_dir = settings.DATA_PATH / "image_metadata" self.metadata_dir.mkdir(parents=True, exist_ok=True) # 图片存储目录 self.images_dir = settings.DATA_PATH / "images" self.images_dir.mkdir(parents=True, exist_ok=True) def _get_chat_metadata_file(self, chat_id: str) -> Path: """获取指定聊天的元数据文件路径""" # chat_id 格式: role_name/chat_name parts = chat_id.split("/") if len(parts) == 2: role_name, chat_name = parts role_dir = self.metadata_dir / role_name role_dir.mkdir(parents=True, exist_ok=True) return role_dir / f"{chat_name}.json" else: # fallback return self.metadata_dir / f"{chat_id.replace('/', '_')}.json" def _load_chat_metadata(self, chat_id: str) -> List[ImageMetadata]: """加载指定聊天的所有图片元数据""" file_path = self._get_chat_metadata_file(chat_id) if not file_path.exists(): return [] try: with open(file_path, 'r', encoding='utf-8') as f: data = json.load(f) return [ImageMetadata(**item) for item in data] except Exception as e: print(f"[ImageMetadata] 加载元数据失败: {e}") return [] def _save_chat_metadata(self, chat_id: str, metadata_list: List[ImageMetadata]): """保存聊天的所有图片元数据""" file_path = self._get_chat_metadata_file(chat_id) try: data = [m.model_dump() for m in metadata_list] with open(file_path, 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False, indent=2) except Exception as e: print(f"[ImageMetadata] 保存元数据失败: {e}") async def add_image( self, chat_id: str, role_name: str, chat_name: str, floor: int, filename: str, filepath: str, prompt: Optional[str] = None, negative_prompt: Optional[str] = None, seed: Optional[int] = None, model: Optional[str] = None, workflow_name: Optional[str] = None, task_id: Optional[str] = None, generation_time: Optional[float] = None, width: Optional[int] = None, height: Optional[int] = None, file_size: Optional[int] = None ) -> ImageMetadata: """ 添加图片元数据 Args: chat_id: 聊天ID role_name: 角色名称 chat_name: 聊天名称 floor: 楼层号 filename: 文件名 filepath: 文件相对路径 prompt: 提示词 negative_prompt: 负面提示词 seed: 随机种子 model: 使用的模型 workflow_name: 工作流名称 task_id: 任务ID generation_time: 生成耗时 width: 图片宽度 height: 图片高度 file_size: 文件大小 Returns: ImageMetadata: 创建的元数据 """ metadata_list = self._load_chat_metadata(chat_id) # 计算 swipe_index same_floor_images = [m for m in metadata_list if m.floor == floor] swipe_index = len(same_floor_images) # 如果这是该楼层的第一张图片,将其他图片的 isCurrentSwipe 设为 False if swipe_index == 0: for m in metadata_list: if m.floor == floor: m.isCurrentSwipe = False metadata = ImageMetadata( id=str(uuid.uuid4()), chatId=chat_id, roleName=role_name, chatName=chat_name, floor=floor, filename=filename, filepath=filepath, prompt=prompt, negativePrompt=negative_prompt, seed=seed, model=model, workflowName=workflow_name, taskId=task_id, generationTime=generation_time, width=width, height=height, fileSize=file_size, swipeIndex=swipe_index, isCurrentSwipe=True ) metadata_list.append(metadata) self._save_chat_metadata(chat_id, metadata_list) return metadata async def get_images_by_chat( self, chat_id: str, floor: Optional[int] = None ) -> List[ImageMetadata]: """ 获取指定聊天的图片列表 Args: chat_id: 聊天ID floor: 楼层号(可选,用于过滤) Returns: 图片元数据列表 """ metadata_list = self._load_chat_metadata(chat_id) if floor is not None: metadata_list = [m for m in metadata_list if m.floor == floor] # 按楼层和 swipe_index 排序 metadata_list.sort(key=lambda m: (m.floor, m.swipeIndex)) return metadata_list async def get_images_by_role(self, role_name: str) -> List[ImageMetadata]: """获取指定角色的所有图片""" all_images = [] role_dir = self.metadata_dir / role_name if not role_dir.exists(): return [] for chat_file in role_dir.glob("*.json"): chat_name = chat_file.stem chat_id = f"{role_name}/{chat_name}" images = self._load_chat_metadata(chat_id) all_images.extend(images) # 按创建时间排序 all_images.sort(key=lambda m: m.createdAt, reverse=True) return all_images async def delete_image(self, chat_id: str, image_id: str) -> bool: """ 删除图片元数据(不删除实际文件) Args: chat_id: 聊天ID image_id: 图片ID Returns: bool: 是否成功删除 """ metadata_list = self._load_chat_metadata(chat_id) # 找到要删除的图片 target_image = None for m in metadata_list: if m.id == image_id: target_image = m break if not target_image: return False floor = target_image.floor swipe_index = target_image.swipeIndex # 删除该图片 metadata_list = [m for m in metadata_list if m.id != image_id] # 重新调整同一楼层其他图片的 swipe_index same_floor_images = [m for m in metadata_list if m.floor == floor] same_floor_images.sort(key=lambda m: m.swipeIndex) for idx, m in enumerate(same_floor_images): m.swipeIndex = idx m.isCurrentSwipe = (idx == 0) # 第一个为当前显示 self._save_chat_metadata(chat_id, metadata_list) return True async def clear_chat_images(self, chat_id: str) -> int: """ 清空指定聊天的所有图片元数据 Args: chat_id: 聊天ID Returns: int: 删除的图片数量 """ metadata_list = self._load_chat_metadata(chat_id) count = len(metadata_list) # 清空元数据文件 file_path = self._get_chat_metadata_file(chat_id) if file_path.exists(): file_path.unlink() return count async def set_current_swipe(self, chat_id: str, image_id: str) -> bool: """ 设置某张图片为当前显示的 swipe Args: chat_id: 聊天ID image_id: 图片ID Returns: bool: 是否成功设置 """ metadata_list = self._load_chat_metadata(chat_id) target_image = None for m in metadata_list: if m.id == image_id: target_image = m break if not target_image: return False floor = target_image.floor # 将同一楼层的所有图片设为非当前 for m in metadata_list: if m.floor == floor: m.isCurrentSwipe = False # 设置目标图片为当前 target_image.isCurrentSwipe = True self._save_chat_metadata(chat_id, metadata_list) return True async def get_gallery_stats(self) -> Dict[str, Any]: """ 获取画廊统计信息 Returns: 统计信息字典 """ stats = { "totalImages": 0, "byRole": {}, "byChat": {} } if not self.metadata_dir.exists(): return stats for role_dir in self.metadata_dir.iterdir(): if not role_dir.is_dir(): continue role_name = role_dir.name role_count = 0 for chat_file in role_dir.glob("*.json"): chat_name = chat_file.stem chat_id = f"{role_name}/{chat_name}" images = self._load_chat_metadata(chat_id) chat_count = len(images) role_count += chat_count stats["totalImages"] += chat_count if chat_count > 0: stats["byChat"][chat_id] = chat_count if role_count > 0: stats["byRole"][role_name] = role_count return stats def get_image_full_path(self, filepath: str) -> Path: """获取图片的完整路径""" return self.images_dir / filepath # 全局实例 image_metadata_service = ImageMetadataService()