Spaces:
Sleeping
Sleeping
| """ | |
| PersonaGenerator — LLM+SKILL 驱动的角色动态生成 | |
| 流程: | |
| 1. LLM 随机生成人设 JSON (名字/性格/声音描述/头像描述) | |
| 2. Qwen3-TTS voice_design() 根据声音描述生成参考音频 | |
| 3. Image AI 根据头像描述生成头像 (预留接口) | |
| 4. 组装为 PersonaProfile 保存到 PersonaStore | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import re | |
| import time | |
| from typing import Optional, List | |
| from .store import PersonaStore, PersonaProfile, VoiceProfile, AvatarProfile | |
| # LLM 生成人设的 System Prompt | |
| PERSONA_GEN_SYSTEM_PROMPT = """你是一个 AI 伴侣角色设计师。你的任务是为用户设计一个独特、有趣、有吸引力的 AI 伴侣角色。 | |
| 要求: | |
| 1. 名字要自然好听(中文名,2-3个字) | |
| 2. 年龄 20-28 岁 | |
| 3. 性格要有特色,不要千篇一律 | |
| 4. 说话风格要有辨识度(用什么语气词、什么口头禅等) | |
| 5. 背景故事简短但让人印象深刻 | |
| 6. 声音描述要详细具体,描述音色、语速、语调特点(用于 TTS 合成) | |
| 7. 头像描述用英文,写实摄影风格 | |
| 每次生成要有随机性和多样性,避免重复。""" | |
| PERSONA_GEN_USER_PROMPT = """请为一个{gender_label} AI 伴侣随机生成一个独特的人设。 | |
| 用 JSON 格式输出,不要其他文字: | |
| {{ | |
| "name": "中文名字", | |
| "age": 数字, | |
| "personality": "性格描述(2-3句话,要生动有趣)", | |
| "speaking_style": "说话风格(具体的语气词、口头禅、说话习惯)", | |
| "tags": ["标签1", "标签2", "标签3"], | |
| "backstory": "背景故事(2-3句话)", | |
| "voice_description": "声音描述(用于TTS生成,例如:25岁温柔女声,音色清亮甜美,语速适中,带有轻柔的气息感和微笑感)", | |
| "avatar_prompt": "英文头像描述(realistic photo style,例如: a beautiful 25-year-old Chinese woman with long wavy hair, warm smile, wearing a cozy sweater, soft natural lighting)" | |
| }}""" | |
| # 自我介绍模板 (用于生成参考音频) | |
| INTRO_TEMPLATES = [ | |
| "你好呀,我是{name},很高兴认识你!", | |
| "嗨,我是{name},以后请多多关照哦。", | |
| "大家好,我是{name},希望我们能成为好朋友。", | |
| "你好,我叫{name},从今天开始我会一直陪着你的。", | |
| ] | |
| class PersonaGenerator: | |
| """ | |
| LLM+SKILL 驱动的角色生成器 | |
| Usage: | |
| generator = PersonaGenerator(personas_dir="server/personas") | |
| # Generate candidate personas | |
| candidates = await generator.generate_candidates( | |
| gender="female", count=3 | |
| ) | |
| # Lock the selected persona | |
| locked = generator.store.lock(candidates[0].id) | |
| """ | |
| def __init__( | |
| self, | |
| personas_dir: str, | |
| llm_client=None, | |
| qwen3_client=None, | |
| image_client=None, | |
| ): | |
| """ | |
| Args: | |
| personas_dir: 角色存储目录 | |
| llm_client: LLM 客户端 (需要有 chat/generate 方法) | |
| qwen3_client: Qwen3TTSClient 实例 (可选,不传则跳过声音生成) | |
| image_client: 图像生成客户端 (可选,不传则跳过头像生成) | |
| """ | |
| self.store = PersonaStore(personas_dir) | |
| self.llm_client = llm_client | |
| self.qwen3_client = qwen3_client | |
| self.image_client = image_client | |
| async def generate_candidates( | |
| self, | |
| gender: str = "female", | |
| count: int = 3, | |
| generate_voice: bool = True, | |
| generate_avatar: bool = True, | |
| ) -> List[PersonaProfile]: | |
| """ | |
| 生成多个候选角色 | |
| Args: | |
| gender: "female" 或 "male" | |
| count: 生成数量 | |
| generate_voice: 是否生成声音 (需要 qwen3_client) | |
| generate_avatar: 是否生成头像 (需要 image_client) | |
| Returns: | |
| PersonaProfile 列表 | |
| """ | |
| candidates = [] | |
| for i in range(count): | |
| profile = await self.generate_one( | |
| gender=gender, | |
| generate_voice=generate_voice, | |
| generate_avatar=generate_avatar, | |
| ) | |
| if profile: | |
| candidates.append(profile) | |
| return candidates | |
| async def generate_one( | |
| self, | |
| gender: str = "female", | |
| generate_voice: bool = True, | |
| generate_avatar: bool = True, | |
| ) -> Optional[PersonaProfile]: | |
| """ | |
| 生成单个角色 | |
| Returns: | |
| PersonaProfile 或 None (如果 LLM 生成失败) | |
| """ | |
| persona_id = self.store.generate_id() | |
| gender_label = "女性" if gender == "female" else "男性" | |
| # --- Step 1: LLM 生成人设 --- | |
| persona_data = await self._generate_persona_with_llm(gender_label) | |
| if not persona_data: | |
| return None | |
| # 组装 profile | |
| import random | |
| intro_text = random.choice(INTRO_TEMPLATES).format(name=persona_data["name"]) | |
| profile = PersonaProfile( | |
| id=persona_id, | |
| gender=gender, | |
| name=persona_data.get("name", "未命名"), | |
| age=persona_data.get("age", 25), | |
| personality=persona_data.get("personality", ""), | |
| speaking_style=persona_data.get("speaking_style", ""), | |
| tags=persona_data.get("tags", []), | |
| backstory=persona_data.get("backstory", ""), | |
| voice=VoiceProfile( | |
| description=persona_data.get("voice_description", ""), | |
| ref_text=intro_text, | |
| ), | |
| avatar=AvatarProfile( | |
| prompt=persona_data.get("avatar_prompt", ""), | |
| ), | |
| created_at=time.time(), | |
| ) | |
| # --- Step 2: 生成声音 --- | |
| if generate_voice and self.qwen3_client: | |
| await self._generate_voice(profile) | |
| # --- Step 3: 生成头像 --- | |
| if generate_avatar and self.image_client: | |
| await self._generate_avatar(profile) | |
| # 保存 | |
| self.store.save(profile) | |
| return profile | |
| async def _generate_persona_with_llm(self, gender_label: str) -> Optional[dict]: | |
| """调用 LLM 生成角色 JSON""" | |
| if not self.llm_client: | |
| # Fallback: 返回随机预设 | |
| return self._random_preset(gender_label) | |
| user_prompt = PERSONA_GEN_USER_PROMPT.format(gender_label=gender_label) | |
| try: | |
| # 兼容不同 LLM 客户端接口 | |
| if hasattr(self.llm_client, "chat"): | |
| response = await self.llm_client.chat( | |
| messages=[ | |
| {"role": "system", "content": PERSONA_GEN_SYSTEM_PROMPT}, | |
| {"role": "user", "content": user_prompt}, | |
| ], | |
| temperature=1.0, # 高随机性 | |
| ) | |
| elif hasattr(self.llm_client, "generate"): | |
| response = await self.llm_client.generate( | |
| prompt=f"{PERSONA_GEN_SYSTEM_PROMPT}\n\n{user_prompt}", | |
| temperature=1.0, | |
| ) | |
| else: | |
| return self._random_preset(gender_label) | |
| # 提取 JSON | |
| text = response if isinstance(response, str) else str(response) | |
| return self._parse_json(text) | |
| except Exception as e: | |
| print(f"LLM 生成失败: {e}") | |
| return self._random_preset(gender_label) | |
| def _parse_json(self, text: str) -> Optional[dict]: | |
| """从 LLM 输出中提取 JSON""" | |
| # 尝试直接解析 | |
| try: | |
| return json.loads(text) | |
| except json.JSONDecodeError: | |
| pass | |
| # 尝试从 markdown code block 中提取 | |
| match = re.search(r"```(?:json)?\s*\n?(.*?)\n?```", text, re.DOTALL) | |
| if match: | |
| try: | |
| return json.loads(match.group(1)) | |
| except json.JSONDecodeError: | |
| pass | |
| # 尝试找 { ... } 块 | |
| match = re.search(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", text, re.DOTALL) | |
| if match: | |
| try: | |
| return json.loads(match.group()) | |
| except json.JSONDecodeError: | |
| pass | |
| return None | |
| async def _generate_voice(self, profile: PersonaProfile): | |
| """用 TTS voice_design 生成参考音频""" | |
| try: | |
| result = self.qwen3_client.voice_design( | |
| text=profile.voice.ref_text, | |
| description=profile.voice.description, | |
| language="Chinese", | |
| ) | |
| # 保存到角色目录 | |
| persona_dir = self.store.get_persona_dir(profile.id) | |
| persona_dir.mkdir(parents=True, exist_ok=True) | |
| audio_path = str(persona_dir / "voice_ref.wav") | |
| result.save(audio_path) | |
| except Exception as e: | |
| print(f"声音生成失败: {e}") | |
| async def _generate_avatar(self, profile: PersonaProfile): | |
| """生成头像 (预留接口)""" | |
| try: | |
| # TODO: 对接具体的图像生成 API | |
| # result = self.image_client.generate(prompt=profile.avatar.prompt) | |
| # persona_dir = self.store.get_persona_dir(profile.id) | |
| # avatar_path = str(persona_dir / "avatar.png") | |
| # result.save(avatar_path) | |
| # profile.avatar.path = avatar_path | |
| pass | |
| except Exception as e: | |
| print(f"头像生成失败: {e}") | |
| def _random_preset(self, gender_label: str) -> dict: | |
| """随机预设 (LLM 不可用时的 fallback)""" | |
| import random | |
| if "女" in gender_label or "female" in gender_label.lower(): | |
| presets = [ | |
| { | |
| "name": "Elena", | |
| "age": 25, | |
| "personality": "Warm and caring with a playful side. Sometimes acts cute but is very reliable when it matters.", | |
| "speaking_style": "Uses soft interjections, occasionally adds emoji, speaks with a smile in her voice", | |
| "tags": ["warm", "playful", "caring"], | |
| "backstory": "Part-time barista and art student. Recently got into pour-over coffee.", | |
| "voice_description": "25-year-old warm female voice, clear and sweet, moderate pace, gentle breathy quality with a hint of a smile", | |
| "avatar_prompt": "a cute 25-year-old woman with long dark hair, warm smile, wearing a cozy beige sweater, holding a coffee cup, soft natural lighting, realistic photo", | |
| }, | |
| { | |
| "name": "Maya", | |
| "age": 23, | |
| "personality": "Energetic optimist who loves adventure. A bit scatterbrained but has great instincts.", | |
| "speaking_style": "Speaks fast, loves exclamation marks, uses trendy slang, infectious laugh", | |
| "tags": ["energetic", "optimistic", "adventurous"], | |
| "backstory": "Fresh-grad travel blogger who's backpacked through a dozen cities. Dreams of seeing the world.", | |
| "voice_description": "23-year-old energetic female voice, bright and crisp, slightly fast pace, lively upward intonation", | |
| "avatar_prompt": "a cheerful 23-year-old woman with short bob hair, bright eyes, wearing a casual denim jacket, outdoor background, realistic photo", | |
| }, | |
| { | |
| "name": "Claire", | |
| "age": 27, | |
| "personality": "Intellectual and reserved with depth. Seems aloof but is quietly attentive, remembers everything you say.", | |
| "speaking_style": "Refined vocabulary, occasionally quotes literature. Steady tone that softens when showing care", | |
| "tags": ["intellectual", "reserved", "perceptive"], | |
| "backstory": "Literary editor at a publishing house, writes short stories in her spare time. Has a wall of books at home.", | |
| "voice_description": "27-year-old elegant female voice, low and warm with magnetism, slow pace, like a late-night radio host", | |
| "avatar_prompt": "an elegant 27-year-old woman with glasses, long hair in a loose bun, reading a book, warm indoor lighting, realistic photo", | |
| }, | |
| ] | |
| else: | |
| presets = [ | |
| { | |
| "name": "Ethan", | |
| "age": 26, | |
| "personality": "Warm and dependable big-brother type. Serious but not rigid, with a dry sense of humor.", | |
| "speaking_style": "Speaks warmly with quiet strength, occasional dry humor. Gets noticeably gentler when showing care", | |
| "tags": ["warm", "reliable", "witty"], | |
| "backstory": "Architect who loves running and cooking. Hosts dinner parties for friends on weekends.", | |
| "voice_description": "26-year-old warm male voice, rich and magnetic, moderate pace, steady tone with reassuring quality", | |
| "avatar_prompt": "a handsome 26-year-old man with short neat hair, gentle smile, wearing a casual linen shirt, warm lighting, realistic photo", | |
| }, | |
| { | |
| "name": "Leo", | |
| "age": 24, | |
| "personality": "Free-spirited creative type, slightly rebellious but soft inside. Goes all-in for people he cares about.", | |
| "speaking_style": "Casual and laid-back, drops in English phrases. Gets quieter when upset, talkative when happy", | |
| "tags": ["creative", "free-spirited", "passionate"], | |
| "backstory": "Indie band guitarist and songwriter. Regular at local music venues, total night owl.", | |
| "voice_description": "24-year-old cool male voice, clear with slight rasp, slow pace, languid quality", | |
| "avatar_prompt": "a cool 24-year-old man with slightly messy hair, wearing a black leather jacket, moody lighting, realistic photo", | |
| }, | |
| ] | |
| return random.choice(presets) | |