* update relation * feat: add relation_type to avatar info structure and update related components - Added `relation_type` to the avatar structured info in `info_presenter.py`. - Updated `AvatarDetail.vue` to utilize the new `relation_type` for displaying avatar relationships. - Modified `RelationRow.vue` to accept `type` as a prop for enhanced relationship representation. - Updated `core.ts` to include `relation_type` in the `RelationInfo` interface. Closes #
185 lines
7.6 KiB
Python
185 lines
7.6 KiB
Python
from __future__ import annotations
|
||
|
||
from typing import Dict, TYPE_CHECKING
|
||
from pathlib import Path
|
||
import random
|
||
|
||
if TYPE_CHECKING:
|
||
from src.classes.avatar import Avatar
|
||
|
||
from src.utils.config import CONFIG
|
||
from src.utils.llm import call_llm_with_task_name
|
||
from src.classes.relation.relations import (
|
||
process_relation_changes,
|
||
get_relation_change_context
|
||
)
|
||
from src.i18n import t
|
||
|
||
story_styles = [
|
||
"Plain narration: Restrained language, minimal embellishment, recording like a bystander.",
|
||
"Emotion in scenery: Expressing emotion through scenery, blending feelings with the setting.",
|
||
"Freehand ancient style: Focus on imagery and metaphor, concise, avoiding obscure archaic language.",
|
||
"Marketplace life: Grounded writing, using colloquialisms, simple and natural, without pretension.",
|
||
"Poetic lyricism: Short sentences and parallelism, sparse use of allusions, avoiding flowery language.",
|
||
"Philosophical fable: Asking questions through events, containing one or two punchlines, without preaching.",
|
||
"Chronicle style: Like a historian's record, orderly events, few adjectives.",
|
||
"Personification of scenery: Slight personification of scenery, embedding aspirations in the view, not overused.",
|
||
"Taoist nature: Tinted with Taoist vocabulary, not obscure, focusing on a single thought.",
|
||
"Buddhist emptiness: Insights of impermanence and emptiness interwoven, light and not mysterious.",
|
||
"Folk storytelling: Like a storyteller's tone but in written language, fast-paced, vivid and interesting.",
|
||
"Elegant scholarly: Scholarly atmosphere, slight touch of citations, without showing off.",
|
||
"Bold and open: Grand words, majestic momentum, informal, expressing feelings directly.",
|
||
"Gorgeous and bizarre: Heavy sensory description, ornate and seductive language, emphasizing the strangeness of light and color.",
|
||
"Cold and concise: Mainly short sentences, every word counts, like metal striking stone, no extra emotional rendering.",
|
||
"Fine line drawing: No decoration, capturing subtle movements and expressions to convey spirit, real and delicate.",
|
||
]
|
||
|
||
|
||
class StoryTeller:
|
||
"""
|
||
故事生成器:基于模板与 LLM,将给定事件扩展为简短的小故事。
|
||
同时负责处理可能的后天关系变化。
|
||
"""
|
||
|
||
TEMPLATE_SINGLE_FILE = "story_single.txt"
|
||
TEMPLATE_DUAL_FILE = "story_dual.txt"
|
||
TEMPLATE_GATHERING_FILE = "story_gathering.txt"
|
||
|
||
@staticmethod
|
||
def _get_template_path(filename: str) -> Path:
|
||
"""获取当前语言环境下的模板路径"""
|
||
return CONFIG.paths.templates / filename
|
||
|
||
@staticmethod
|
||
def _build_avatar_infos(*actors: "Avatar") -> Dict[str, dict]:
|
||
"""
|
||
构建角色信息字典。
|
||
- 双人故事:第一个角色使用 expanded_info(包含共同事件),第二个使用普通 info
|
||
- 单人故事:使用 expanded_info
|
||
"""
|
||
non_null = [a for a in actors if a is not None]
|
||
avatar_infos: Dict[str, dict] = {}
|
||
|
||
if len(non_null) >= 2:
|
||
avatar_infos[non_null[0].name] = non_null[0].get_expanded_info(other_avatar=non_null[1], detailed=True)
|
||
avatar_infos[non_null[1].name] = non_null[1].get_info(detailed=True)
|
||
elif non_null:
|
||
avatar_infos[non_null[0].name] = non_null[0].get_expanded_info(detailed=True)
|
||
|
||
return avatar_infos
|
||
|
||
@staticmethod
|
||
def _build_template_data(event: str, res: str, avatar_infos: Dict[str, dict], prompt: str, *actors: "Avatar") -> dict:
|
||
"""构建模板渲染所需的数据字典"""
|
||
|
||
# 默认空关系列表
|
||
avatar_name_1 = ""
|
||
avatar_name_2 = ""
|
||
|
||
world_info = actors[0].world.static_info
|
||
|
||
# 如果有两个有效角色,计算可能的关系
|
||
non_null = [a for a in actors if a is not None]
|
||
if len(non_null) >= 2:
|
||
avatar_name_1 = non_null[0].name
|
||
avatar_name_2 = non_null[1].name
|
||
|
||
return {
|
||
"world_info": world_info,
|
||
"avatar_infos": avatar_infos,
|
||
"avatar_name_1": avatar_name_1,
|
||
"avatar_name_2": avatar_name_2,
|
||
"event": event,
|
||
"res": res,
|
||
"style": t(random.choice(story_styles)),
|
||
"story_prompt": prompt,
|
||
}
|
||
|
||
@staticmethod
|
||
def _make_fallback_story(event: str, res: str, style: str) -> str:
|
||
"""生成降级文案"""
|
||
# 不再显示 style,避免出戏
|
||
return f"{event}。{res}。"
|
||
|
||
@staticmethod
|
||
async def tell_story(event: str, res: str, *actors: "Avatar", prompt: str = "", allow_relation_changes: bool = False) -> str:
|
||
"""
|
||
生成小故事(异步版本)。
|
||
根据 allow_relation_changes 参数选择模板:
|
||
- True: 使用 story_dual.txt,支持关系变化(需要至少2个角色)
|
||
- False: 使用 story_single.txt,仅生成故事(无论角色数量)
|
||
|
||
Args:
|
||
event: 事件描述
|
||
res: 结果描述
|
||
*actors: 参与的角色(1-2个)
|
||
prompt: 可选的故事提示词
|
||
allow_relation_changes: 是否允许故事导致关系变化,默认为False(单人模式)
|
||
"""
|
||
non_null = [a for a in actors if a is not None]
|
||
|
||
# 只有当允许关系变化且有至少2个角色时,才使用双人模板
|
||
is_dual = allow_relation_changes and len(non_null) >= 2
|
||
|
||
template_file = StoryTeller.TEMPLATE_DUAL_FILE if is_dual else StoryTeller.TEMPLATE_SINGLE_FILE
|
||
template_path = StoryTeller._get_template_path(template_file)
|
||
|
||
avatar_infos = StoryTeller._build_avatar_infos(*actors)
|
||
infos = StoryTeller._build_template_data(event, res, avatar_infos, prompt, *actors)
|
||
|
||
# 移除了 try-except 块,允许异常向上冒泡,以便 Fail Fast
|
||
data = await call_llm_with_task_name("story_teller", template_path, infos)
|
||
story = data.get("story", "").strip()
|
||
|
||
if story:
|
||
return story
|
||
|
||
return StoryTeller._make_fallback_story(event, res, infos["style"])
|
||
|
||
@staticmethod
|
||
async def tell_gathering_story(
|
||
gathering_info: str,
|
||
events_text: str,
|
||
details_text: str,
|
||
related_avatars: list["Avatar"],
|
||
prompt: str = ""
|
||
) -> str:
|
||
"""
|
||
生成聚会/拍卖会等多人事件的故事。
|
||
通用接口,适配 story_gathering.txt 模板。
|
||
|
||
Args:
|
||
gathering_info: 事件本身的设定信息(如地点、背景、规则等)
|
||
events_text: 发生的具体事件/交互记录
|
||
details_text: 详细信息(包括角色信息、物品信息等)
|
||
related_avatars: 参与的角色列表(主要用于获取世界背景信息)
|
||
prompt: 额外提示词
|
||
"""
|
||
if not related_avatars:
|
||
return events_text
|
||
|
||
# 使用第一个角色的世界信息
|
||
world_info = related_avatars[0].world.static_info
|
||
|
||
infos = {
|
||
"world_info": world_info,
|
||
"gathering_info": gathering_info,
|
||
"events": events_text,
|
||
"details": details_text,
|
||
"style": t(random.choice(story_styles)),
|
||
"story_prompt": prompt
|
||
}
|
||
|
||
# 增加 token 上限以支持长故事
|
||
template_path = StoryTeller._get_template_path(StoryTeller.TEMPLATE_GATHERING_FILE)
|
||
data = await call_llm_with_task_name("story_teller", template_path, infos)
|
||
story = data.get("story", "").strip()
|
||
|
||
if story:
|
||
return story
|
||
|
||
return events_text
|
||
|
||
|
||
__all__ = ["StoryTeller"]
|